mirror of
https://github.com/lancedb/lancedb.git
synced 2026-08-31 10:38:31 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ef752c2e3f | |||
| 0f6a355593 | |||
| a6ec35502a | |||
| a0bb1f7597 | |||
| 2562e117b2 | |||
| 134a265ee2 |
+1
-1
@@ -1,5 +1,5 @@
|
||||
[tool.bumpversion]
|
||||
current_version = "0.38.0"
|
||||
current_version = "0.38.0-beta.11"
|
||||
parse = """(?x)
|
||||
(?P<major>0|[1-9]\\d*)\\.
|
||||
(?P<minor>0|[1-9]\\d*)\\.
|
||||
|
||||
@@ -44,27 +44,3 @@ updates:
|
||||
python-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
# The npm ecosystem covers pnpm lockfiles. There are two separate installs:
|
||||
# the bindings themselves and the examples, which have their own lockfile.
|
||||
# As with cargo and pip above, only bump the lockfile — the version ranges
|
||||
# in package.json are our consumers' constraints, not ours.
|
||||
- package-ecosystem: npm
|
||||
directory: /nodejs
|
||||
schedule:
|
||||
interval: weekly
|
||||
versioning-strategy: lockfile-only
|
||||
groups:
|
||||
nodejs-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
- package-ecosystem: npm
|
||||
directory: /nodejs/examples
|
||||
schedule:
|
||||
interval: weekly
|
||||
versioning-strategy: lockfile-only
|
||||
groups:
|
||||
nodejs-examples-deps:
|
||||
patterns:
|
||||
- "*"
|
||||
|
||||
@@ -29,14 +29,12 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
- uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 11.1.1
|
||||
node-version: "18"
|
||||
# These rules are disabled because Github will always ensure there
|
||||
# is a blank line between the title and the body and Github will
|
||||
# word wrap the description field to ensure a reasonable max line
|
||||
# length.
|
||||
- run: npm install @commitlint/config-conventional
|
||||
- run: >
|
||||
echo 'module.exports = {
|
||||
"rules": {
|
||||
@@ -45,11 +43,7 @@ jobs:
|
||||
"body-leading-blank": [0, "always"]
|
||||
}
|
||||
}' > .commitlintrc.js
|
||||
- run: >
|
||||
pnpm dlx
|
||||
--package @commitlint/cli@21.2.2
|
||||
--package @commitlint/config-conventional@21.2.2
|
||||
commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG
|
||||
- run: npx commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG
|
||||
env:
|
||||
COMMIT_MSG: >
|
||||
${{ github.event.pull_request.title }}
|
||||
@@ -60,7 +54,7 @@ jobs:
|
||||
with:
|
||||
script: |
|
||||
const message = `**ACTION NEEDED**
|
||||
|
||||
|
||||
Lance follows the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) for release automation.
|
||||
|
||||
The PR title and description are used as the merge commit message.\
|
||||
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
|
||||
with:
|
||||
# Restricted to http(s) on purpose. Much of docs/src is generated
|
||||
# API reference (the js/ tree comes from `pnpm run docs` in nodejs)
|
||||
# API reference (the js/ tree comes from `npm run docs` in nodejs)
|
||||
# and the hand-written pages use mkdocstrings cross-references and
|
||||
# nav-relative paths that only resolve in the site mkdocs builds,
|
||||
# not in this checkout, so relative links would be reported as
|
||||
|
||||
@@ -55,7 +55,9 @@ jobs:
|
||||
- name: Set up node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
node-version: 20
|
||||
cache: 'npm'
|
||||
cache-dependency-path: docs/package-lock.json
|
||||
- name: Install node dependencies
|
||||
working-directory: nodejs
|
||||
run: |
|
||||
|
||||
@@ -47,8 +47,9 @@ jobs:
|
||||
version: 11.1.1
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# Build on a supported LTS; the matrix job below covers every
|
||||
# Node version the library claims to support.
|
||||
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL
|
||||
# in October. The library itself still supports Node >= 18
|
||||
# (see test matrix below).
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: nodejs/pnpm-lock.yaml
|
||||
@@ -83,7 +84,7 @@ jobs:
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [ "22", "24", "26" ]
|
||||
node-version: [ "18", "20" ]
|
||||
runs-on: "ubuntu-22.04"
|
||||
defaults:
|
||||
run:
|
||||
@@ -100,9 +101,9 @@ jobs:
|
||||
- uses: actions/setup-node@v6
|
||||
name: Setup Node.js 24 for build
|
||||
with:
|
||||
# Build and install once on a fixed version so the generated docs
|
||||
# are identical across matrix legs; the tests below then run on each
|
||||
# supported Node version.
|
||||
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL
|
||||
# in October. Build/install runs on Node 24; tests run on the
|
||||
# matrix version below using direct jest invocation.
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: nodejs/pnpm-lock.yaml
|
||||
@@ -151,9 +152,9 @@ jobs:
|
||||
S3_TEST: "1"
|
||||
# Newer @smithy/core uses dynamic ESM imports.
|
||||
NODE_OPTIONS: "--experimental-vm-modules"
|
||||
# Invoke the installed jest binary directly; the pnpm shim is set up
|
||||
# against the build-phase Node, not the version selected above.
|
||||
run: node_modules/.bin/jest --verbose
|
||||
# Invoke jest directly because pnpm 11 itself requires Node 22+
|
||||
# while the matrix tests on older Node versions.
|
||||
run: npx jest --verbose
|
||||
- name: Test examples
|
||||
working-directory: ./
|
||||
env:
|
||||
@@ -163,7 +164,7 @@ jobs:
|
||||
run: |
|
||||
python ci/mock_openai.py &
|
||||
cd nodejs/examples
|
||||
node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose
|
||||
npx jest --testEnvironment jest-environment-node-single-context --verbose
|
||||
macos:
|
||||
timeout-minutes: 30
|
||||
# macos-15 ships a newer linker; the older macos-14 linker fails to insert
|
||||
@@ -184,7 +185,8 @@ jobs:
|
||||
version: 11.1.1
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
# pnpm 11 requires Node >= 22.13.
|
||||
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL
|
||||
# in October.
|
||||
node-version: 24
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: nodejs/pnpm-lock.yaml
|
||||
|
||||
@@ -40,31 +40,40 @@ jobs:
|
||||
- target: aarch64-apple-darwin
|
||||
host: macos-latest
|
||||
features: fp16kernels
|
||||
# Fat LTO was ~111 of this job's ~113 minutes.
|
||||
lto: thin
|
||||
codegen_units: 16
|
||||
pre_build: |-
|
||||
brew install protobuf
|
||||
# Fat LTO (the workspace default in .cargo/config.toml) is
|
||||
# single-threaded and is the peak-memory step of the build. On
|
||||
# this runner it accounted for ~111 of the job's ~113 minutes,
|
||||
# making it the critical path of the entire publish pipeline.
|
||||
# ThinLTO parallelizes it across the runner's cores, for a few
|
||||
# percent of runtime performance.
|
||||
export CARGO_PROFILE_RELEASE_LTO=thin
|
||||
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||
- target: x86_64-pc-windows-msvc
|
||||
host: windows-2025
|
||||
features: ","
|
||||
# The lower peak also keeps this on the standard 4-core runner.
|
||||
lto: thin
|
||||
codegen_units: 16
|
||||
pre_build: |-
|
||||
choco install --no-progress protoc ninja nasm
|
||||
tail -n 1000 /c/ProgramData/chocolatey/logs/chocolatey.log
|
||||
# There is an issue where choco doesn't add nasm to the path
|
||||
export PATH="$PATH:/c/Program Files/NASM"
|
||||
nasm -v
|
||||
# See the ThinLTO note on aarch64-apple-darwin above. Keeping
|
||||
# peak memory down is also what lets this run on the standard
|
||||
# 4-core runner: the 8-core larger runner was only needed to
|
||||
# stop fat LTO from OOMing rustc-LLVM.
|
||||
export CARGO_PROFILE_RELEASE_LTO=thin
|
||||
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||
- target: aarch64-pc-windows-msvc
|
||||
host: windows-2025
|
||||
features: ","
|
||||
lto: thin
|
||||
codegen_units: 16
|
||||
pre_build: |-
|
||||
choco install --no-progress protoc
|
||||
rustup target add aarch64-pc-windows-msvc
|
||||
# See the ThinLTO note on aarch64-apple-darwin above.
|
||||
export CARGO_PROFILE_RELEASE_LTO=thin
|
||||
export CARGO_PROFILE_RELEASE_CODEGEN_UNITS=16
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
host: ubuntu-latest
|
||||
features: fp16kernels
|
||||
@@ -94,14 +103,6 @@ jobs:
|
||||
# https://github.com/napi-rs/napi-rs/blob/main/debian-aarch64.Dockerfile
|
||||
docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
|
||||
features: "fp16kernels"
|
||||
# Fat LTO OOM-killed rustc every nightly; even with lld it peaked
|
||||
# at 31391 MiB of the runner's 32 GiB.
|
||||
lto: thin
|
||||
codegen_units: 16
|
||||
# arm64 Linux links through GNU `ld` where x86_64 defaults to
|
||||
# `rust-lld`, which is why only arm64 OOM'd. lld cut the largest
|
||||
# linker process 7.0 -> 4.0 GiB (lancedb/sophon#7313).
|
||||
linker: /tmp/aarch64-lld-clang
|
||||
pre_build: |-
|
||||
set -e &&
|
||||
apt-get update &&
|
||||
@@ -111,30 +112,9 @@ jobs:
|
||||
# AT_HWCAP2 (added in Linux 3.17). Define it for aws-lc-sys.
|
||||
export CFLAGS="$CFLAGS -DAT_HWCAP2=26" &&
|
||||
rustup target add aarch64-unknown-linux-gnu
|
||||
# Not `&&`-chained: in dash, errexit does not fire for a
|
||||
# non-final command in an `&&` list, so failures were ignored.
|
||||
#
|
||||
# A wrapper rather than `-C link-arg` because the per-target
|
||||
# rustflags variable does not reach every unit that links, while
|
||||
# the linker variable does. `clang` because GCC silently ignores
|
||||
# `-fuse-ld=lld` unless built with lld support. Two echoes
|
||||
# because printf's newline escape gets rewritten to `;` between
|
||||
# here and the container.
|
||||
echo '#!/bin/sh' > /tmp/aarch64-lld-clang
|
||||
echo 'exec clang --target=aarch64-unknown-linux-gnu --sysroot=/usr/aarch64-unknown-linux-gnu/aarch64-unknown-linux-gnu/sysroot --gcc-toolchain=/usr/aarch64-unknown-linux-gnu -fuse-ld=lld "$@"' >> /tmp/aarch64-lld-clang
|
||||
chmod 0755 /tmp/aarch64-lld-clang
|
||||
# Fail now, not at the cdylib link ~30 minutes later. Linking at
|
||||
# all also proves lld resolved; clang errors out when it cannot.
|
||||
echo 'int main(void){return 0;}' > /tmp/probe.c
|
||||
/tmp/aarch64-lld-clang /tmp/probe.c -o /tmp/probe
|
||||
readelf -h /tmp/probe | grep AArch64
|
||||
- target: aarch64-unknown-linux-musl
|
||||
host: ubuntu-2404-8x-x64
|
||||
features: ","
|
||||
# Fat LTO took the whole runner down. lld cannot help: it died
|
||||
# inside rustc's LLVM, before any linker was spawned.
|
||||
lto: thin
|
||||
codegen_units: 16
|
||||
pre_build: |-
|
||||
set -e &&
|
||||
sudo apt-get update &&
|
||||
@@ -143,19 +123,6 @@ jobs:
|
||||
export EXTRA_ARGS="-x"
|
||||
name: build - ${{ matrix.settings.target }}
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
# On the job, not exported from `pre_build`: `Swatinem/rust-cache` hashes
|
||||
# `CARGO_*` into its cache key before any step runs, so a step-local export
|
||||
# leaves the key unchanged while cargo still rebuilds cold. The ThinLTO
|
||||
# legs had been doing that every run.
|
||||
#
|
||||
# Not `RUSTFLAGS`: setting it, even to "", discards every config-file
|
||||
# rustflag, silently dropping .cargo/config.toml's `target-cpu` and
|
||||
# `target-feature` from the published binaries.
|
||||
env:
|
||||
CARGO_PROFILE_RELEASE_LTO: ${{ matrix.settings.lto || 'fat' }}
|
||||
CARGO_PROFILE_RELEASE_CODEGEN_UNITS: ${{ matrix.settings.codegen_units || '1' }}
|
||||
# Empty elsewhere: a per-target variable is only read for that triple.
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER: ${{ matrix.settings.linker }}
|
||||
defaults:
|
||||
run:
|
||||
working-directory: nodejs
|
||||
@@ -168,7 +135,8 @@ jobs:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
# pnpm 11 requires Node >= 22.13.
|
||||
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL
|
||||
# in October.
|
||||
node-version: 24
|
||||
cache: pnpm
|
||||
cache-dependency-path: nodejs/pnpm-lock.yaml
|
||||
@@ -201,15 +169,19 @@ jobs:
|
||||
# creating ref). The nightly cadence also keeps entries inside
|
||||
# GitHub's 7-day eviction window, which a tag-only trigger would not.
|
||||
save-if: ${{ github.ref == 'refs/heads/main' }}
|
||||
# Docker builds can use rust-cache too: the workspace is bind-mounted, so
|
||||
# `target/` lives on the host and rust-cache's prune keeps the entry
|
||||
# small.
|
||||
# Docker builds can use rust-cache too. `target/` already lives on the
|
||||
# host because the whole workspace is bind-mounted into the container, and
|
||||
# rust-cache's prune and save run host-side, so they can manage it -- which
|
||||
# is what keeps the entry to dependency artifacts rather than a multi-GB
|
||||
# copy of everything.
|
||||
#
|
||||
# Two differences from the native builds. The container's CARGO_HOME is
|
||||
# bind-mounted from `.cargo-cache` rather than ~/.cargo, so that is cached
|
||||
# explicitly. And the key uses the *host* rustc version, not the compiler
|
||||
# that built these artifacts -- safe, since cargo fingerprints the real
|
||||
# one; a base-image bump just costs one cold build.
|
||||
# bind-mounted from `.cargo-cache` rather than the host's ~/.cargo, so that
|
||||
# has to be cached explicitly. And the key is derived from the *host* rustc
|
||||
# version, which is not the compiler that produced these artifacts; that is
|
||||
# safe because cargo fingerprints the real compiler and rebuilds on a
|
||||
# mismatch, it just means a base-image toolchain bump costs one cold build
|
||||
# instead of invalidating the key.
|
||||
- name: Cache cargo (docker builds)
|
||||
uses: Swatinem/rust-cache@v2
|
||||
if: ${{ matrix.settings.docker }}
|
||||
@@ -238,19 +210,14 @@ jobs:
|
||||
# cache step above saves. Previously the registry mounts pointed at
|
||||
# `.cargo/...`, a path nothing cached, so the container re-downloaded
|
||||
# the whole crate registry on every run.
|
||||
#
|
||||
# `docker run` inherits nothing; `-e NAME` carries the job's `env:` in.
|
||||
options: "--user 0:0 -v ${{ github.workspace }}/.cargo-cache/git/db:/usr/local/cargo/git/db \
|
||||
-v ${{ github.workspace }}/.cargo-cache/registry/cache:/usr/local/cargo/registry/cache \
|
||||
-v ${{ github.workspace }}/.cargo-cache/registry/index:/usr/local/cargo/registry/index \
|
||||
-e CARGO_PROFILE_RELEASE_LTO \
|
||||
-e CARGO_PROFILE_RELEASE_CODEGEN_UNITS \
|
||||
-e CARGO_TARGET_AARCH64_UNKNOWN_LINUX_GNU_LINKER \
|
||||
-v ${{ github.workspace }}:/build -w /build/nodejs"
|
||||
run: |
|
||||
set -e
|
||||
${{ matrix.settings.pre_build }}
|
||||
node_modules/.bin/napi build --platform --release \
|
||||
npx napi build --platform --release \
|
||||
--features ${{ matrix.settings.features }} \
|
||||
--target ${{ matrix.settings.target }} \
|
||||
--dts ../lancedb/native.d.ts \
|
||||
@@ -270,7 +237,7 @@ jobs:
|
||||
- name: Build
|
||||
run: |
|
||||
${{ matrix.settings.pre_build }}
|
||||
node_modules/.bin/napi build --platform --release \
|
||||
npx napi build --platform --release \
|
||||
--features ${{ matrix.settings.features }} \
|
||||
--target ${{ matrix.settings.target }} \
|
||||
--dts ../lancedb/native.d.ts \
|
||||
@@ -289,18 +256,6 @@ jobs:
|
||||
if: always()
|
||||
run: df -h
|
||||
shell: bash
|
||||
- name: Report peak memory
|
||||
if: always() && runner.os == 'Linux'
|
||||
shell: bash
|
||||
run: |
|
||||
peak=$(find /sys/fs/cgroup -name memory.peak -readable \
|
||||
-exec cat {} + 2>/dev/null | sort -n | tail -1)
|
||||
if [ -n "$peak" ]; then
|
||||
echo "peak memory: $((peak / 1024 / 1024)) MiB"
|
||||
else
|
||||
echo "peak memory: unavailable (no readable cgroup v2 memory.peak)"
|
||||
fi
|
||||
free -g || true
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
@@ -338,7 +293,7 @@ jobs:
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
host: ubuntu-2404-8x-arm64
|
||||
node:
|
||||
- '22'
|
||||
- '20'
|
||||
runs-on: ${{ matrix.settings.host }}
|
||||
defaults:
|
||||
run:
|
||||
@@ -384,9 +339,9 @@ jobs:
|
||||
- name: Move built files
|
||||
run: cp dist/native.d.ts dist/native.js dist/*.node lancedb/
|
||||
- name: Test bindings
|
||||
# Invoke the installed jest binary directly; the pnpm shim is set up
|
||||
# against the install-phase Node, not the version selected above.
|
||||
run: node_modules/.bin/jest --verbose
|
||||
# Invoke jest directly because pnpm 11 itself requires Node 22+
|
||||
# while the matrix tests on older Node versions.
|
||||
run: npx jest --verbose
|
||||
publish:
|
||||
name: Publish
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Update package-lock.json
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
lfs: true
|
||||
- uses: ./.github/workflows/update_package_lock
|
||||
with:
|
||||
github_token: ${{ secrets.LANCEDB_RELEASE_TOKEN }}
|
||||
@@ -0,0 +1,22 @@
|
||||
name: Update NodeJs package-lock.json
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
lfs: true
|
||||
- uses: ./.github/workflows/update_package_lock_nodejs
|
||||
with:
|
||||
github_token: ${{ secrets.LANCEDB_RELEASE_TOKEN }}
|
||||
@@ -20,10 +20,7 @@ repos:
|
||||
hooks:
|
||||
- id: local-biome-check
|
||||
name: biome check
|
||||
# Use the biome from nodejs/package.json rather than a separately
|
||||
# pinned one: the two drifted apart and disagreed on formatting, so
|
||||
# this hook rejected code that `pnpm lint` accepted.
|
||||
entry: nodejs/node_modules/.bin/biome check --config-path nodejs/biome.json nodejs/
|
||||
entry: npx @biomejs/biome@1.8.3 check --config-path nodejs/biome.json nodejs/
|
||||
language: system
|
||||
types: [text]
|
||||
files: "nodejs/.*"
|
||||
|
||||
@@ -38,7 +38,7 @@ Before committing changes, run formatting for every language you touched. At min
|
||||
* Rust changes: run `cargo fmt --all`.
|
||||
* Python changes: run `ruff format .` and `ruff check .` from the repository root,
|
||||
and run targeted tests through `cd python && uv run ...`.
|
||||
* TypeScript changes: run the relevant `pnpm` lint, format, build, and docs commands in `nodejs`.
|
||||
* TypeScript changes: run the relevant `npm`/`pnpm` lint, format, build, and docs commands in `nodejs`.
|
||||
|
||||
Before creating a PR, the exact value passed to `gh pr create --title` must follow
|
||||
Conventional Commits, such as `fix: support nested field paths in native index creation`
|
||||
@@ -101,12 +101,12 @@ Python bindings changes:
|
||||
TypeScript bindings changes:
|
||||
|
||||
1. Add napi-rs method binding on `Table` in `nodejs/src/table.rs`.
|
||||
2. Run `pnpm build` to generate TypeScript definitions.
|
||||
2. Run `npm run build` to generate TypeScript definitions.
|
||||
3. Add typescript method on abstract class `Table` in `nodejs/src/table.ts`.
|
||||
4. Add concrete method on `LocalTable` class in `nodejs/src/native_table.ts`.
|
||||
* Note: despite the name, this class is also used for remote tables.
|
||||
5. Add test in `nodejs/__test__/table.test.ts`.
|
||||
6. Run `pnpm run docs` to generate TypeScript documentation.
|
||||
6. Run `npm run docs` to generate TypeScript documentation.
|
||||
|
||||
## Python API reference
|
||||
|
||||
|
||||
Generated
+47
-70
@@ -1597,9 +1597,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
version = "0.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601"
|
||||
dependencies = [
|
||||
"cfg-if 1.0.4",
|
||||
"cpufeatures 0.3.0",
|
||||
@@ -3455,9 +3455,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
|
||||
|
||||
[[package]]
|
||||
name = "fsst"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f727719438dfdb74f358a347c91ff81b6e7084a6421f34de3e473ce271f10caa"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"rand 0.9.5",
|
||||
@@ -4816,9 +4815,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
|
||||
|
||||
[[package]]
|
||||
name = "lance"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be781f40c7a75f9eae2188a2f71174acb7a360dca97163db40b041d0828dea48"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrow",
|
||||
@@ -4890,9 +4888,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-arrow"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb97fd9875f3036d7c2561aa5b16eb87b80ccabaa4eeb5e6099b19cc662f1cd8"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -4914,8 +4911,7 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lance-arrow-scalar"
|
||||
version = "58.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "771f68b04b47f3addf781116f65061808de94b05e1e9411c23c18f32d14ebe79"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -4929,20 +4925,17 @@ dependencies = [
|
||||
[[package]]
|
||||
name = "lance-arrow-stats"
|
||||
version = "58.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dd47ec33c90bf29f688fd02118e37d3a5ad5c339caa3163f89e417dc0867001f"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
"half",
|
||||
"lance-arrow-scalar",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lance-bitpacking"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f45658c5b2dc9aada41b66ee44b83af3fa888b7385ae414bae951b12a9f1cd3"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrayref",
|
||||
"crunchy",
|
||||
@@ -4952,9 +4945,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-core"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27af3df3a7d08897efccd04461df31cedf0880c4b86a055ddce48e423d27f967"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -4991,9 +4983,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-datafusion"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c377f837df5296e92f9fad724c83c1bef4e74d5af6e5a9312e9307e1dead8614"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5022,9 +5013,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-datagen"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e1a5065fa4bc184e36e32681f10f8f4680ad8cedc9377b4c088dce8c5b8da"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5041,9 +5031,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-derive"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "13e5e95e0fd3d74f7938f4bee623041421b323b5c61f242c8622a1f48a202527"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -5052,9 +5041,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-encoding"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1625653c55c65f3426e281f6e29b54c603f38a40bd4cebd707bd4f3ea48be6c5"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -5087,9 +5075,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-file"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7e13c9266b478fc98f36ee19347c4658f7a6613fed77778b1a455fe1b88552e"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-arith",
|
||||
"arrow-array",
|
||||
@@ -5120,9 +5107,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-index"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b0e0cb95f2c4f341c4dd04ac60f6a89ea26a6f75e09570225cbda4854c8b088e"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arc-swap",
|
||||
"arrow",
|
||||
@@ -5186,9 +5172,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-index-core"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "79ccd371977c1f7168da259d66ad37154f23146f093d46136bc7f79559f00f2c"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5210,9 +5195,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-io"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "414d50997391b1ac83dc183c1612fdff88f58b959806078dc4c5e465154566de"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5252,9 +5236,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-linalg"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ff55b152ef23a56d7ba7e4d1b2c9cf0cc79aef6ee607c115597557ea4059f41"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5268,9 +5251,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "09991c13ab282b731e323619613914e08da9cc82b312f904e58c128b23f2f0e3"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"async-trait",
|
||||
@@ -5282,9 +5264,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-namespace-impls"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0ec0bc005f6bb8f120774eb4a9ba02e10463d8338167a46cbb1391d46680a174"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-ipc",
|
||||
@@ -5337,9 +5318,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-select"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e8f676a2a1837cc85b77feb5326d3296827da964e40d67144f646563302a6ce9"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5353,9 +5333,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-table"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bd33054347395048b1d842dfb85a13f7801392c2da5f39425db62f00a481744b"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"arrow-array",
|
||||
@@ -5395,9 +5374,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-testing"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ecc9ad9ae24f045dfddd538a39e55a28fa7e1ca6ad9f23e20d4087eaf2bb66f7"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-schema",
|
||||
@@ -5410,9 +5388,8 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lance-tokenizer"
|
||||
version = "11.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3bfa6f0164c8b7056150f5682ce4d415a335b59b04c479873fda04b200117d27"
|
||||
version = "12.0.0-beta.2"
|
||||
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e"
|
||||
dependencies = [
|
||||
"frostem",
|
||||
"icu_segmenter",
|
||||
@@ -5425,7 +5402,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lancedb"
|
||||
version = "0.38.0-beta.15"
|
||||
version = "0.38.0-beta.11"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"anyhow",
|
||||
@@ -5513,7 +5490,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lancedb-nodejs"
|
||||
version = "0.38.0-beta.15"
|
||||
version = "0.38.0-beta.11"
|
||||
dependencies = [
|
||||
"arrow-array",
|
||||
"arrow-buffer",
|
||||
@@ -5538,7 +5515,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "lancedb-python"
|
||||
version = "0.38.0-beta.15"
|
||||
version = "0.38.0-beta.11"
|
||||
dependencies = [
|
||||
"arrow",
|
||||
"async-trait",
|
||||
|
||||
+14
-14
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
|
||||
rust-version = "1.91.0"
|
||||
|
||||
[workspace.dependencies]
|
||||
lance = { "version" = "=11.0.0", default-features = false }
|
||||
lance-core = "=11.0.0"
|
||||
lance-datagen = "=11.0.0"
|
||||
lance-file = "=11.0.0"
|
||||
lance-io = { "version" = "=11.0.0", default-features = false }
|
||||
lance-index = "=11.0.0"
|
||||
lance-linalg = "=11.0.0"
|
||||
lance-namespace = "=11.0.0"
|
||||
lance-namespace-impls = { "version" = "=11.0.0", default-features = false }
|
||||
lance-table = "=11.0.0"
|
||||
lance-testing = "=11.0.0"
|
||||
lance-datafusion = "=11.0.0"
|
||||
lance-encoding = "=11.0.0"
|
||||
lance-arrow = "=11.0.0"
|
||||
lance = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-core = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datagen = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-file = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-io = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-index = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-linalg = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-namespace-impls = { "version" = "=12.0.0-beta.2", default-features = false, "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-table = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-testing = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-datafusion = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-encoding = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lance-arrow = { "version" = "=12.0.0-beta.2", "tag" = "v12.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
|
||||
lancedb = { path = "rust/lancedb", default-features = false }
|
||||
ahash = "0.8"
|
||||
# Note that this one does not include pyarrow
|
||||
|
||||
@@ -5,5 +5,5 @@ licenses:
|
||||
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 nodejs && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml
|
||||
cd nodejs && pnpm dlx license-checker@25 --markdown --out NODEJS_THIRD_PARTY_LICENSES.md
|
||||
cd nodejs && npx license-checker --markdown --out NODEJS_THIRD_PARTY_LICENSES.md
|
||||
cd java && ./mvnw license:aggregate-add-third-party -q
|
||||
|
||||
@@ -12,12 +12,16 @@ done
|
||||
# This updates the lockfile without building
|
||||
cargo metadata --quiet > /dev/null
|
||||
|
||||
pushd nodejs || exit 1
|
||||
npm install --package-lock-only --silent
|
||||
popd
|
||||
|
||||
if git diff --quiet --exit-code; then
|
||||
echo "No lockfile changes to commit; skipping amend."
|
||||
elif $AMEND; then
|
||||
git add Cargo.lock
|
||||
git add Cargo.lock nodejs/package-lock.json
|
||||
git commit --amend --no-edit
|
||||
else
|
||||
git add Cargo.lock
|
||||
git add Cargo.lock nodejs/package-lock.json
|
||||
git commit -m "Update lockfiles"
|
||||
fi
|
||||
|
||||
@@ -131,13 +131,18 @@ allow = [
|
||||
"BSD-3-Clause",
|
||||
"ISC",
|
||||
"Unicode-3.0",
|
||||
"Unicode-DFS-2016",
|
||||
"Zlib",
|
||||
"CC0-1.0",
|
||||
"MPL-2.0",
|
||||
"BSL-1.0",
|
||||
"OpenSSL",
|
||||
# 0BSD ("BSD Zero Clause") is effectively public domain — no attribution
|
||||
# required. Pulled in by `mock_instant`.
|
||||
"0BSD",
|
||||
# bzip2-1.0.6 is the permissive upstream bzip2 license (BSD-like). Pulled
|
||||
# in by `libbz2-rs-sys`, the pure-Rust bzip2 implementation.
|
||||
"bzip2-1.0.6",
|
||||
# CDLA-Permissive-2.0 is a permissive data license used by `webpki-roots`
|
||||
# for the Mozilla CA root bundle. Data-only, distribution-compatible.
|
||||
"CDLA-Permissive-2.0",
|
||||
@@ -145,7 +150,12 @@ allow = [
|
||||
confidence-threshold = 0.8
|
||||
# Per-crate license exceptions: allow a license for a specific crate only,
|
||||
# rather than globally via the `allow` list above.
|
||||
exceptions = []
|
||||
exceptions = [
|
||||
# CDDL-1.0 (copyleft) is pulled in only as a dev/profiling dependency via
|
||||
# `inferno` -> `pprof` -> `lance-testing`; it is a test dependency that we
|
||||
# do not distribute, so scope the allowance to `inferno` alone.
|
||||
{ allow = ["CDDL-1.0"], crate = "inferno" },
|
||||
]
|
||||
# Crates whose license cannot be determined from Cargo metadata but whose
|
||||
# license we've manually confirmed from upstream. Keep this list minimal.
|
||||
[[licenses.clarify]]
|
||||
|
||||
+8
-11
@@ -47,24 +47,22 @@ pytest -vv python/tests/docs
|
||||
|
||||
### Checking typescript examples
|
||||
|
||||
The examples depend on `@lancedb/lancedb` at `file:../dist`, so the package must be
|
||||
built before running the tests. This uses pnpm; see the
|
||||
[Typescript contributing guide](../nodejs/CONTRIBUTING.md) for the toolchain setup.
|
||||
The `@lancedb/lancedb` package must be built before running the tests:
|
||||
|
||||
```shell
|
||||
pushd nodejs
|
||||
pnpm install
|
||||
pnpm build
|
||||
npm ci
|
||||
npm run build
|
||||
popd
|
||||
```
|
||||
|
||||
Then you can run the examples by going to the `nodejs/examples` directory, which is a
|
||||
separate pnpm package with its own lockfile:
|
||||
Then you can run the examples by going to the `nodejs/examples` directory and
|
||||
running the tests like a normal npm package:
|
||||
|
||||
```shell
|
||||
pushd nodejs/examples
|
||||
pnpm install
|
||||
pnpm test
|
||||
npm ci
|
||||
npm test
|
||||
popd
|
||||
```
|
||||
|
||||
@@ -86,7 +84,6 @@ The new files should be checked into the repository.
|
||||
|
||||
```shell
|
||||
pushd nodejs
|
||||
# `pnpm docs` would invoke pnpm's built-in `docs` command, not the script.
|
||||
pnpm run docs
|
||||
npm run docs
|
||||
popd
|
||||
```
|
||||
|
||||
Generated
+135
@@ -0,0 +1,135 @@
|
||||
{
|
||||
"name": "lancedb-docs-test",
|
||||
"version": "1.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "lancedb-docs-test",
|
||||
"version": "1.0.0",
|
||||
"license": "Apache 2",
|
||||
"dependencies": {
|
||||
"apache-arrow": "file:../node/node_modules/apache-arrow",
|
||||
"vectordb": "file:../node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.8",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
},
|
||||
"../node": {
|
||||
"name": "vectordb",
|
||||
"version": "0.21.2-beta.0",
|
||||
"cpu": [
|
||||
"x64",
|
||||
"arm64"
|
||||
],
|
||||
"license": "Apache-2.0",
|
||||
"os": [
|
||||
"darwin",
|
||||
"linux",
|
||||
"win32"
|
||||
],
|
||||
"dependencies": {
|
||||
"@neon-rs/load": "^0.0.74",
|
||||
"axios": "^1.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@neon-rs/cli": "^0.0.160",
|
||||
"@types/chai": "^4.3.4",
|
||||
"@types/chai-as-promised": "^7.1.5",
|
||||
"@types/mocha": "^10.0.1",
|
||||
"@types/node": "^18.16.2",
|
||||
"@types/sinon": "^10.0.15",
|
||||
"@types/temp": "^0.9.1",
|
||||
"@types/uuid": "^9.0.3",
|
||||
"@typescript-eslint/eslint-plugin": "^5.59.1",
|
||||
"apache-arrow-old": "npm:apache-arrow@13.0.0",
|
||||
"cargo-cp-artifact": "^0.1",
|
||||
"chai": "^4.3.7",
|
||||
"chai-as-promised": "^7.1.1",
|
||||
"eslint": "^8.39.0",
|
||||
"eslint-config-standard-with-typescript": "^34.0.1",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-n": "^15.7.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"mocha": "^10.2.0",
|
||||
"openai": "^4.24.1",
|
||||
"sinon": "^15.1.0",
|
||||
"temp": "^0.9.4",
|
||||
"ts-node": "^10.9.1",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"typedoc": "^0.24.7",
|
||||
"typedoc-plugin-markdown": "^3.15.3",
|
||||
"typescript": "^5.1.0",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@lancedb/vectordb-darwin-arm64": "0.21.2-beta.0",
|
||||
"@lancedb/vectordb-darwin-x64": "0.21.2-beta.0",
|
||||
"@lancedb/vectordb-linux-arm64-gnu": "0.21.2-beta.0",
|
||||
"@lancedb/vectordb-linux-x64-gnu": "0.21.2-beta.0",
|
||||
"@lancedb/vectordb-win32-x64-msvc": "0.21.2-beta.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@apache-arrow/ts": "^14.0.2",
|
||||
"apache-arrow": "^14.0.2"
|
||||
}
|
||||
},
|
||||
"../node/node_modules/apache-arrow": {
|
||||
"version": "14.0.2",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@types/command-line-args": "5.2.0",
|
||||
"@types/command-line-usage": "5.0.2",
|
||||
"@types/node": "20.3.0",
|
||||
"@types/pad-left": "2.1.1",
|
||||
"command-line-args": "5.2.1",
|
||||
"command-line-usage": "7.0.1",
|
||||
"flatbuffers": "23.5.26",
|
||||
"json-bignum": "^0.0.3",
|
||||
"pad-left": "^2.1.0",
|
||||
"tslib": "^2.5.3"
|
||||
},
|
||||
"bin": {
|
||||
"arrow2csv": "bin/arrow2csv.js"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.11.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.11.8.tgz",
|
||||
"integrity": "sha512-i7omyekpPTNdv4Jb/Rgqg0RU8YqLcNsI12quKSDkRXNfx7Wxdm6HhK1awT3xTgEkgxPn3bvnSpiEAc7a7Lpyow==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~5.26.4"
|
||||
}
|
||||
},
|
||||
"node_modules/apache-arrow": {
|
||||
"resolved": "../node/node_modules/apache-arrow",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.3.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz",
|
||||
"integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "5.26.5",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
|
||||
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/vectordb": {
|
||||
"resolved": "../node",
|
||||
"link": true
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "lancedb-docs-test",
|
||||
"version": "1.0.0",
|
||||
"description": "auto-generated tests from doc",
|
||||
"author": "dev@lancedb.com",
|
||||
"license": "Apache 2",
|
||||
"dependencies": {
|
||||
"apache-arrow": "file:../node/node_modules/apache-arrow",
|
||||
"vectordb": "file:../node"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc -b && cd ../node && npm run build-release",
|
||||
"example": "npm run build && node",
|
||||
"test": "npm run build && ls dist/*.js | xargs -n 1 node"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.11.8",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`:
|
||||
<dependency>
|
||||
<groupId>com.lancedb</groupId>
|
||||
<artifactId>lancedb-core</artifactId>
|
||||
<version>0.38.0</version>
|
||||
<version>0.38.0-beta.11</version>
|
||||
</dependency>
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"include": [
|
||||
"src/*.ts",
|
||||
],
|
||||
"compilerOptions": {
|
||||
"target": "es2022",
|
||||
"module": "nodenext",
|
||||
"declaration": true,
|
||||
"outDir": "./dist",
|
||||
"strict": true,
|
||||
"allowJs": true,
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"exclude": [
|
||||
"./dist/*",
|
||||
]
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
<parent>
|
||||
<groupId>com.lancedb</groupId>
|
||||
<artifactId>lancedb-parent</artifactId>
|
||||
<version>0.38.0-final.0</version>
|
||||
<version>0.38.0-beta.11</version>
|
||||
<relativePath>../pom.xml</relativePath>
|
||||
</parent>
|
||||
|
||||
|
||||
+2
-2
@@ -6,7 +6,7 @@
|
||||
|
||||
<groupId>com.lancedb</groupId>
|
||||
<artifactId>lancedb-parent</artifactId>
|
||||
<version>0.38.0-final.0</version>
|
||||
<version>0.38.0-beta.11</version>
|
||||
<packaging>pom</packaging>
|
||||
<name>${project.artifactId}</name>
|
||||
<description>LanceDB Java SDK Parent POM</description>
|
||||
@@ -28,7 +28,7 @@
|
||||
<properties>
|
||||
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||
<arrow.version>15.0.0</arrow.version>
|
||||
<lance-core.version>11.0.0</lance-core.version>
|
||||
<lance-core.version>12.0.0-beta.2</lance-core.version>
|
||||
<spotless.skip>false</spotless.skip>
|
||||
<spotless.version>2.30.0</spotless.version>
|
||||
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
[package]
|
||||
name = "lancedb-nodejs"
|
||||
edition.workspace = true
|
||||
version = "0.38.0"
|
||||
version = "0.38.0-beta.11"
|
||||
publish = false
|
||||
license.workspace = true
|
||||
description.workspace = true
|
||||
|
||||
@@ -5,8 +5,8 @@ import packageJson = require("../package.json");
|
||||
|
||||
describe("package metadata", () => {
|
||||
it("requires Node.js type declarations compatible with the runtime", () => {
|
||||
expect(packageJson.engines.node).toBe(">= 22");
|
||||
expect(packageJson.peerDependencies["@types/node"]).toBe(">=22");
|
||||
expect(packageJson.engines.node).toBe(">= 18");
|
||||
expect(packageJson.peerDependencies["@types/node"]).toBe(">=18");
|
||||
expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({
|
||||
optional: true,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import * as http from "http";
|
||||
import { RequestListener } from "http";
|
||||
import packageJson = require("../package.json");
|
||||
import {
|
||||
ClientConfig,
|
||||
Connection,
|
||||
@@ -71,13 +70,7 @@ async function withMockDatabase(
|
||||
try {
|
||||
await callback(db);
|
||||
} finally {
|
||||
// `close()` alone leaves the port bound until keep-alive sockets drain, so
|
||||
// a single failing test would cascade into EADDRINUSE for every test after
|
||||
// it. Destroy the connections and wait for the port to actually be free.
|
||||
await new Promise<void>((resolve) => {
|
||||
server.closeAllConnections();
|
||||
server.close(() => resolve());
|
||||
});
|
||||
server.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +131,7 @@ describe("remote connection", () => {
|
||||
(req, res) => {
|
||||
expect(req.headers["x-api-key"]).toEqual("fake");
|
||||
expect(req.headers["user-agent"]).toEqual(
|
||||
`LanceDB-Node-Client/${packageJson.version}`,
|
||||
`LanceDB-Node-Client/${process.env.npm_package_version}`,
|
||||
);
|
||||
|
||||
const body = JSON.stringify({ tables: [] });
|
||||
|
||||
@@ -8,8 +8,7 @@
|
||||
"//1": "--experimental-vm-modules is needed to run jest with sentence-transformers",
|
||||
"//2": "--testEnvironment is needed to run jest with sentence-transformers",
|
||||
"//3": "See: https://github.com/huggingface/transformers.js/issues/57",
|
||||
"//4": "jest is invoked by its JS entry, not node_modules/.bin/jest: under pnpm that path is a shell shim, which `node` cannot execute",
|
||||
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js --testEnvironment jest-environment-node-single-context --verbose",
|
||||
"test": "node --experimental-vm-modules node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose",
|
||||
"lint": "biome check *.ts && biome format *.ts",
|
||||
"lint-ci": "biome ci .",
|
||||
"lint-fix": "biome check --write *.ts && pnpm format",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-darwin-arm64",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": ["darwin"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.darwin-arm64.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-arm64-gnu",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.linux-arm64-gnu.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-arm64-musl",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": ["linux"],
|
||||
"cpu": ["arm64"],
|
||||
"main": "lancedb.linux-arm64-musl.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-x64-gnu",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.linux-x64-gnu.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-linux-x64-musl",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": ["linux"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.linux-x64-musl.node",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-win32-arm64-msvc",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lancedb/lancedb-win32-x64-msvc",
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"os": ["win32"],
|
||||
"cpu": ["x64"],
|
||||
"main": "lancedb.win32-x64-msvc.node",
|
||||
|
||||
Generated
+11106
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -11,7 +11,7 @@
|
||||
"ann"
|
||||
],
|
||||
"private": false,
|
||||
"version": "0.38.0",
|
||||
"version": "0.38.0-beta.11",
|
||||
"main": "dist/index.js",
|
||||
"exports": {
|
||||
".": "./dist/index.js",
|
||||
@@ -67,7 +67,7 @@
|
||||
"timeout": "3m"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 22"
|
||||
"node": ">= 18"
|
||||
},
|
||||
"packageManager": "pnpm@11.1.1",
|
||||
"cpu": ["x64", "arm64"],
|
||||
@@ -101,7 +101,7 @@
|
||||
"openai": "4.29.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": ">=22",
|
||||
"@types/node": ">=18",
|
||||
"apache-arrow": ">=15.0.0 <=18.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lancedb-python"
|
||||
version = "0.38.0"
|
||||
version = "0.38.0-beta.11"
|
||||
publish = false
|
||||
edition.workspace = true
|
||||
description = "Python bindings for LanceDB"
|
||||
|
||||
@@ -16,6 +16,7 @@ from typing import (
|
||||
Iterable,
|
||||
List,
|
||||
Literal,
|
||||
Mapping,
|
||||
Optional,
|
||||
Union,
|
||||
)
|
||||
@@ -687,17 +688,35 @@ class DBConnection(EnforceOverrides):
|
||||
"""
|
||||
raise NotImplementedError("serialize is not supported for this connection type")
|
||||
|
||||
def create_function(self, definition: UdfDefinition) -> FunctionVersion:
|
||||
def create_function(
|
||||
self,
|
||||
definition: UdfDefinition,
|
||||
*,
|
||||
secrets: Optional[Mapping[str, str]] = None,
|
||||
) -> FunctionVersion:
|
||||
"""Register a scalar Python UDF and wait for its immutable version.
|
||||
|
||||
``secrets`` must contain exactly the names declared by
|
||||
``@udf(secrets=[...])``. Values are sent in the create request and
|
||||
stored server-side in the private execution artifact; returned
|
||||
Function and Job metadata contain only the declared names.
|
||||
This is the blocking counterpart of :meth:`create_function_async`.
|
||||
Local connections raise ``NotImplementedError``.
|
||||
"""
|
||||
return self.create_function_async(definition).wait()
|
||||
return self.create_function_async(definition, secrets=secrets).wait()
|
||||
|
||||
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]:
|
||||
def create_function_async(
|
||||
self,
|
||||
definition: UdfDefinition,
|
||||
*,
|
||||
secrets: Optional[Mapping[str, str]] = None,
|
||||
) -> Job[FunctionVersion]:
|
||||
"""Register a scalar Python UDF through the remote Function catalog.
|
||||
|
||||
``secrets`` must contain exactly the names declared by
|
||||
``@udf(secrets=[...])``. Values are sent in the create request and
|
||||
stored server-side in the private execution artifact; returned
|
||||
Function and Job metadata contain only the declared names.
|
||||
Submission returns a typed job. The immutable Function version becomes
|
||||
available only when :meth:`Job.wait` succeeds. Local connections raise
|
||||
``NotImplementedError``.
|
||||
@@ -1405,8 +1424,13 @@ class LanceDBConnection(DBConnection):
|
||||
return Job(self._conn.job(job_id))
|
||||
|
||||
@override
|
||||
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]:
|
||||
job = LOOP.run(self._conn.create_function_async(definition))
|
||||
def create_function_async(
|
||||
self,
|
||||
definition: UdfDefinition,
|
||||
*,
|
||||
secrets: Optional[Mapping[str, str]] = None,
|
||||
) -> Job[FunctionVersion]:
|
||||
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
|
||||
return Job(job)
|
||||
|
||||
@override
|
||||
@@ -2225,17 +2249,24 @@ class AsyncConnection(object):
|
||||
return AsyncJob(self._inner.job(job_id))
|
||||
|
||||
async def create_function_async(
|
||||
self, definition: UdfDefinition
|
||||
self,
|
||||
definition: UdfDefinition,
|
||||
*,
|
||||
secrets: Optional[Mapping[str, str]] = None,
|
||||
) -> AsyncJob[FunctionVersion]:
|
||||
"""Register a scalar Python UDF through the remote Function catalog.
|
||||
|
||||
``secrets`` must contain exactly the names declared by
|
||||
``@udf(secrets=[...])``. Values are sent in the create request and
|
||||
stored server-side in the private execution artifact; returned
|
||||
Function and Job metadata contain only the declared names.
|
||||
The returned typed job resolves to the immutable Function version.
|
||||
Local connections raise ``NotImplementedError``.
|
||||
"""
|
||||
if not isinstance(definition, UdfDefinition):
|
||||
raise TypeError("create_function_async requires a @udf definition")
|
||||
inner = await self._inner.create_function_async(
|
||||
definition.registration_request.to_canonical_json()
|
||||
definition._submission_json(secrets)
|
||||
)
|
||||
return _typed_job(inner, FunctionVersion.from_json)
|
||||
|
||||
|
||||
+126
-246
@@ -4,7 +4,7 @@
|
||||
"""Canonical Function values exchanged with LanceDB Enterprise services.
|
||||
|
||||
These immutable models contain client/wire state only. Catalog persistence,
|
||||
environment bake, and execution are owned by Sophon.
|
||||
environment bake, secret resolution, and execution are owned by Sophon.
|
||||
``RefreshColumnResult`` is also the backend-neutral result of a local
|
||||
expression-backed refresh job.
|
||||
"""
|
||||
@@ -49,25 +49,11 @@ from pydantic import (
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from .schema import is_blob_v2_field as _is_blob_v2_field
|
||||
|
||||
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
|
||||
_UInt32 = conint(strict=True, ge=0, le=2**32 - 1)
|
||||
_UInt64 = conint(strict=True, ge=0, le=2**64 - 1)
|
||||
|
||||
|
||||
def _validate_gpu_wire_marker(value: Any) -> bool:
|
||||
if value is not True:
|
||||
raise ValueError("runtime.gpu must be true")
|
||||
return True
|
||||
|
||||
|
||||
def _normalize_gpu_marker(value: bool) -> Optional[bool]:
|
||||
if not isinstance(value, bool):
|
||||
raise ValueError("gpu must be a boolean")
|
||||
return True if value else None
|
||||
|
||||
|
||||
class _FrozenDict(dict):
|
||||
def _immutable(self, *args, **kwargs):
|
||||
raise TypeError("remote canonical values are immutable")
|
||||
@@ -243,7 +229,7 @@ class PythonEnvironmentSpec(_RemoteValue):
|
||||
|
||||
|
||||
class PythonRuntimeSpec(_RemoteValue):
|
||||
"""Remote runtime definition with environment values.
|
||||
"""Remote runtime definition with non-secret environment values.
|
||||
|
||||
V1 supports ``kind="python"``. Newer runtime kinds remain readable, while
|
||||
their unknown payload fields are intentionally not retained by the client.
|
||||
@@ -253,23 +239,6 @@ class PythonRuntimeSpec(_RemoteValue):
|
||||
python_version: Optional[str] = None
|
||||
environment: Optional[PythonEnvironmentSpec] = None
|
||||
env: Optional[Mapping[str, str]] = None
|
||||
gpu: Optional[bool] = None
|
||||
|
||||
@model_validator(mode="before")
|
||||
@classmethod
|
||||
def _discard_unknown_runtime_payload(cls, value):
|
||||
if isinstance(value, Mapping):
|
||||
kind = value.get("kind")
|
||||
if isinstance(kind, str) and kind not in {"python", "python_v2"}:
|
||||
return {"kind": kind}
|
||||
return value
|
||||
|
||||
@field_validator("gpu", mode="before")
|
||||
@classmethod
|
||||
def _validate_gpu_marker(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
return _validate_gpu_wire_marker(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_runtime_kind(self):
|
||||
@@ -278,28 +247,18 @@ class PythonRuntimeSpec(_RemoteValue):
|
||||
raise ValueError("python runtime requires python_version")
|
||||
if self.environment is None:
|
||||
raise ValueError("python runtime requires environment")
|
||||
if self.gpu is not None:
|
||||
raise ValueError("python runtime with gpu requires kind='python_v2'")
|
||||
elif self.kind == "python_v2":
|
||||
if self.python_version is None:
|
||||
raise ValueError("python_v2 runtime requires python_version")
|
||||
if self.environment is None:
|
||||
raise ValueError("python_v2 runtime requires environment")
|
||||
if self.gpu is None:
|
||||
raise ValueError("python_v2 runtime requires gpu")
|
||||
else:
|
||||
object.__setattr__(self, "python_version", None)
|
||||
object.__setattr__(self, "environment", None)
|
||||
object.__setattr__(self, "env", None)
|
||||
object.__setattr__(self, "gpu", None)
|
||||
return self
|
||||
|
||||
|
||||
class FunctionVersion(_RemoteValue):
|
||||
"""An exact immutable Function version returned by Enterprise.
|
||||
|
||||
The GPU execution requirement is part of this identity. CPU and memory sizing,
|
||||
priority, concurrency, and retry policy belong to the execution platform.
|
||||
Scheduling resources, priority, concurrency, and retry policy belong to
|
||||
the submitting Job and are not part of this identity.
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -309,6 +268,7 @@ class FunctionVersion(_RemoteValue):
|
||||
runtime: PythonRuntimeSpec
|
||||
runtime_digest: str
|
||||
environment_digest: str
|
||||
required_secrets: tuple[str, ...] = ()
|
||||
created_at: str
|
||||
|
||||
def __call__(self, **inputs: Any) -> FunctionApplication:
|
||||
@@ -370,12 +330,17 @@ class FunctionVersion(_RemoteValue):
|
||||
|
||||
|
||||
class FunctionRegistrationRequest(_RemoteValue):
|
||||
"""Stable remote registration envelope produced by :func:`udf`."""
|
||||
"""Stable remote registration envelope produced by :func:`udf`.
|
||||
|
||||
Only secret names are represented. Secret values are supplied separately
|
||||
when the definition is submitted and are not part of this durable value.
|
||||
"""
|
||||
|
||||
name: str
|
||||
artifact: FunctionArtifactRequest
|
||||
signature: FunctionSignature
|
||||
runtime: PythonRuntimeSpec
|
||||
required_secrets: tuple[str, ...] = ()
|
||||
|
||||
|
||||
class FunctionVersionRef(_OpenRemoteValue):
|
||||
@@ -520,7 +485,27 @@ class RefreshColumnResult(_RemoteValue):
|
||||
|
||||
|
||||
_FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
|
||||
_FUNCTION_BLOB_V2_TYPE = "blob_v2"
|
||||
_SECRET_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
||||
# Keep this byte limit aligned with Sophon's MAX_FUNCTION_SECRET_VALUE_BYTES.
|
||||
_MAX_FUNCTION_SECRET_VALUE_BYTES = 64 * 1024
|
||||
_MAX_FUNCTION_SECRET_VALUES_BYTES = 512 * 1024
|
||||
|
||||
|
||||
def _validate_secret_value(name: str, value: Any) -> str:
|
||||
"""Validate one secret value before building the create request."""
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(f"Function secret {name!r} value must be a string")
|
||||
if not value:
|
||||
raise ValueError(f"Function secret {name!r} value must be non-empty")
|
||||
if "\0" in value:
|
||||
raise ValueError(f"Function secret {name!r} value must not contain NUL")
|
||||
value_bytes = len(value.encode("utf-8"))
|
||||
if value_bytes > _MAX_FUNCTION_SECRET_VALUE_BYTES:
|
||||
raise ValueError(
|
||||
f"Function secret {name!r} value exceeds the "
|
||||
f"{_MAX_FUNCTION_SECRET_VALUE_BYTES}-byte limit"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
_GRAMMAR_PRIMITIVES = (
|
||||
@@ -537,7 +522,6 @@ _GRAMMAR_PRIMITIVES = (
|
||||
(pa.float32(), "float32"),
|
||||
(pa.float64(), "float64"),
|
||||
(pa.string(), "utf8"),
|
||||
(pa.large_string(), "large_utf8"),
|
||||
(pa.binary(), "binary"),
|
||||
(pa.date32(), "date32"),
|
||||
(pa.date64(), "date64"),
|
||||
@@ -545,177 +529,31 @@ _GRAMMAR_PRIMITIVES = (
|
||||
|
||||
|
||||
def _canonical_arrow_type(data_type: pa.DataType) -> str:
|
||||
"""The compact Function grammar, or canonical exact JSON for nested types."""
|
||||
grammar = _grammar_arrow_type(data_type)
|
||||
if grammar is not None:
|
||||
return grammar
|
||||
exact = _exact_arrow_type(data_type)
|
||||
return json.dumps(exact, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
|
||||
|
||||
|
||||
def _grammar_arrow_type(data_type: pa.DataType) -> Optional[str]:
|
||||
"""The server's V1 Function type grammar. Anything outside it is rejected
|
||||
here rather than at registration."""
|
||||
for candidate, name in _GRAMMAR_PRIMITIVES:
|
||||
if data_type == candidate:
|
||||
return name
|
||||
if pa.types.is_list(data_type) or pa.types.is_large_list(data_type):
|
||||
item = _grammar_list_item(data_type)
|
||||
if item is None:
|
||||
return None
|
||||
prefix = "list" if pa.types.is_list(data_type) else "large_list"
|
||||
return f"{prefix}<{item}>"
|
||||
return f"{prefix}<{_canonical_list_item(data_type)}>"
|
||||
if pa.types.is_fixed_size_list(data_type) and data_type.list_size > 0:
|
||||
item = _grammar_list_item(data_type)
|
||||
if item is not None:
|
||||
return f"fixed_size_list<{item}, {data_type.list_size}>"
|
||||
return None
|
||||
return (
|
||||
f"fixed_size_list<{_canonical_list_item(data_type)}, {data_type.list_size}>"
|
||||
)
|
||||
raise TypeError(f"unsupported Arrow type for Function signature: {data_type}")
|
||||
|
||||
|
||||
def _grammar_list_item(data_type: pa.DataType) -> Optional[str]:
|
||||
def _canonical_list_item(data_type: pa.DataType) -> str:
|
||||
"""The grammar names only the item type; it always means a non-nullable
|
||||
child called `item`, so other child properties require exact JSON."""
|
||||
child called `item`, so any other child metadata cannot be represented."""
|
||||
child = data_type.value_field
|
||||
if child.name != "item" or child.nullable or child.metadata:
|
||||
return None
|
||||
return _grammar_arrow_type(child.type)
|
||||
|
||||
|
||||
def _validate_exact_arrow_field(field: pa.Field) -> None:
|
||||
if not field.name:
|
||||
raise TypeError(
|
||||
"unsupported Arrow type for Function signature: field names "
|
||||
"must not be empty"
|
||||
"unsupported Arrow type for Function signature: list items must be a "
|
||||
f"non-nullable field named 'item', got {child}"
|
||||
)
|
||||
if _is_blob_v2_field(field):
|
||||
if not _has_supported_blob_v2_layout(field):
|
||||
raise TypeError(
|
||||
"unsupported Arrow type for Function signature: lance.blob.v2 "
|
||||
f"requires a supported Blob storage layout, got {field}"
|
||||
)
|
||||
elif field.metadata:
|
||||
raise TypeError(
|
||||
"unsupported Arrow type for Function signature: field metadata "
|
||||
f"is not supported, got {field}"
|
||||
)
|
||||
|
||||
|
||||
def _has_supported_blob_v2_layout(field: pa.Field) -> bool:
|
||||
data_type = field.type
|
||||
if isinstance(data_type, pa.ExtensionType):
|
||||
data_type = data_type.storage_type
|
||||
if not pa.types.is_struct(data_type):
|
||||
return False
|
||||
|
||||
fields = tuple(data_type)
|
||||
|
||||
def matches(spec, compare_nullable) -> bool:
|
||||
return len(fields) == len(spec) and all(
|
||||
actual.name == name
|
||||
and actual.type == expected_type
|
||||
and (not check_nullable or actual.nullable == nullable)
|
||||
for actual, (name, expected_type, nullable), check_nullable in zip(
|
||||
fields, spec, compare_nullable
|
||||
)
|
||||
)
|
||||
|
||||
logical_minimal = (
|
||||
("data", pa.large_binary(), True),
|
||||
("uri", pa.utf8(), True),
|
||||
)
|
||||
logical_full = logical_minimal + (
|
||||
("position", pa.uint64(), True),
|
||||
("size", pa.uint64(), True),
|
||||
)
|
||||
prepared = (
|
||||
("kind", pa.uint8(), True),
|
||||
("data", pa.large_binary(), True),
|
||||
("uri", pa.utf8(), True),
|
||||
("blob_id", pa.uint32(), True),
|
||||
("blob_size", pa.uint64(), True),
|
||||
("position", pa.uint64(), True),
|
||||
)
|
||||
descriptor = (
|
||||
("kind", pa.uint8(), False),
|
||||
("position", pa.uint64(), False),
|
||||
("size", pa.uint64(), False),
|
||||
("blob_id", pa.uint32(), False),
|
||||
("blob_uri", pa.utf8(), False),
|
||||
)
|
||||
return (
|
||||
matches(logical_minimal, (True, True))
|
||||
or matches(logical_full, (True, True, False, False))
|
||||
or matches(prepared, (True,) * len(prepared))
|
||||
or matches(descriptor, (False,) * len(descriptor))
|
||||
)
|
||||
|
||||
|
||||
def _canonical_arrow_field(field: pa.Field) -> str:
|
||||
_validate_exact_arrow_field(field)
|
||||
if _is_blob_v2_field(field):
|
||||
return _FUNCTION_BLOB_V2_TYPE
|
||||
return _canonical_arrow_type(field.type)
|
||||
|
||||
|
||||
def _exact_arrow_field(field: pa.Field) -> dict[str, Any]:
|
||||
_validate_exact_arrow_field(field)
|
||||
if _is_blob_v2_field(field):
|
||||
raise TypeError(
|
||||
"unsupported Arrow type for Function signature: nested Blob v2 "
|
||||
"fields are not supported; declare Blob parameters or named result "
|
||||
"fields directly"
|
||||
)
|
||||
value = {
|
||||
"name": field.name,
|
||||
"nullable": field.nullable,
|
||||
"type": _exact_arrow_type(field.type),
|
||||
}
|
||||
return value
|
||||
|
||||
|
||||
def _exact_arrow_type(data_type: pa.DataType) -> dict[str, Any]:
|
||||
for candidate, name in _GRAMMAR_PRIMITIVES:
|
||||
if data_type == candidate:
|
||||
return {"type": name}
|
||||
if pa.types.is_struct(data_type):
|
||||
fields = list(data_type)
|
||||
names = [field.name for field in fields]
|
||||
if not fields or len(set(names)) != len(names):
|
||||
raise TypeError(
|
||||
"unsupported Arrow type for Function signature: structs must have "
|
||||
"non-empty, uniquely named fields"
|
||||
)
|
||||
return {
|
||||
"type": "struct",
|
||||
"fields": [_exact_arrow_field(field) for field in fields],
|
||||
}
|
||||
if (
|
||||
pa.types.is_list(data_type)
|
||||
or pa.types.is_large_list(data_type)
|
||||
or pa.types.is_fixed_size_list(data_type)
|
||||
):
|
||||
if pa.types.is_fixed_size_list(data_type):
|
||||
if data_type.value_field.name != "item":
|
||||
raise TypeError(
|
||||
"unsupported Arrow type for Function signature: fixed-size list "
|
||||
"items must be named 'item'"
|
||||
)
|
||||
if data_type.list_size <= 0:
|
||||
raise TypeError(
|
||||
f"unsupported Arrow type for Function signature: {data_type}"
|
||||
)
|
||||
value: dict[str, Any] = {
|
||||
"type": (
|
||||
"list"
|
||||
if pa.types.is_list(data_type)
|
||||
else "large_list"
|
||||
if pa.types.is_large_list(data_type)
|
||||
else "fixed_size_list"
|
||||
),
|
||||
"fields": [_exact_arrow_field(data_type.value_field)],
|
||||
}
|
||||
if pa.types.is_fixed_size_list(data_type):
|
||||
value["length"] = data_type.list_size
|
||||
return value
|
||||
raise TypeError(f"unsupported Arrow type for Function signature: {data_type}")
|
||||
return _canonical_arrow_type(child.type)
|
||||
|
||||
|
||||
def _list_of(item: pa.DataType) -> pa.DataType:
|
||||
@@ -789,15 +627,8 @@ def _callable_parameters(function: Callable[..., Any]) -> tuple[inspect.Paramete
|
||||
|
||||
def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutput:
|
||||
if isinstance(output, pa.Schema):
|
||||
if output.metadata:
|
||||
raise TypeError("Function output schema metadata is not supported")
|
||||
fields = tuple(output)
|
||||
elif (
|
||||
isinstance(output, pa.Field)
|
||||
and not _is_blob_v2_field(output)
|
||||
and pa.types.is_struct(output.type)
|
||||
):
|
||||
_validate_exact_arrow_field(output)
|
||||
elif isinstance(output, pa.Field) and pa.types.is_struct(output.type):
|
||||
if output.nullable:
|
||||
raise ValueError("Function output must be non-nullable")
|
||||
fields = tuple(output.type)
|
||||
@@ -813,12 +644,11 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp
|
||||
raise TypeError(
|
||||
"output_schema must be a PyArrow DataType, Field, or Schema"
|
||||
)
|
||||
_validate_exact_arrow_field(field)
|
||||
if field.nullable:
|
||||
raise ValueError("Function output must be non-nullable")
|
||||
return FunctionOutput(
|
||||
kind="scalar",
|
||||
arrow_type=_canonical_arrow_field(field),
|
||||
arrow_type=_canonical_arrow_type(field.type),
|
||||
nullable=False,
|
||||
)
|
||||
|
||||
@@ -826,8 +656,6 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp
|
||||
raise ValueError("named-struct Function output must contain at least one field")
|
||||
if any(field.nullable for field in fields):
|
||||
raise ValueError("Function output fields must be non-nullable")
|
||||
for field in fields:
|
||||
_validate_exact_arrow_field(field)
|
||||
names = [field.name for field in fields]
|
||||
if len(set(names)) != len(names):
|
||||
raise ValueError("Function output field names must be unique")
|
||||
@@ -836,7 +664,7 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp
|
||||
fields=tuple(
|
||||
FunctionResultField(
|
||||
name=field.name,
|
||||
arrow_type=_canonical_arrow_field(field),
|
||||
arrow_type=_canonical_arrow_type(field.type),
|
||||
nullable=False,
|
||||
)
|
||||
for field in fields
|
||||
@@ -856,10 +684,6 @@ def _infer_signature(
|
||||
if input_schema is not None:
|
||||
if not isinstance(input_schema, pa.Schema):
|
||||
raise TypeError("input_schema must be a PyArrow Schema")
|
||||
if input_schema.metadata:
|
||||
raise TypeError("Function input schema metadata is not supported")
|
||||
for field in input_schema:
|
||||
_validate_exact_arrow_field(field)
|
||||
expected = tuple(parameter.name for parameter in parameters)
|
||||
actual = tuple(input_schema.names)
|
||||
if actual != expected:
|
||||
@@ -870,7 +694,7 @@ def _infer_signature(
|
||||
inputs = tuple(
|
||||
FunctionParameter(
|
||||
name=field.name,
|
||||
arrow_type=_canonical_arrow_field(field),
|
||||
arrow_type=_canonical_arrow_type(field.type),
|
||||
nullable=field.nullable,
|
||||
)
|
||||
for field in input_schema
|
||||
@@ -893,9 +717,7 @@ def _infer_signature(
|
||||
inputs.append(
|
||||
FunctionParameter(
|
||||
name=parameter.name,
|
||||
arrow_type=_canonical_arrow_field(
|
||||
pa.field(parameter.name, data_type, nullable=nullable)
|
||||
),
|
||||
arrow_type=_canonical_arrow_type(data_type),
|
||||
nullable=nullable,
|
||||
)
|
||||
)
|
||||
@@ -1114,8 +936,8 @@ class UdfDefinition:
|
||||
output_schema: Optional[pa.DataType | pa.Field | pa.Schema],
|
||||
pip: tuple[str, ...],
|
||||
env: Mapping[str, str],
|
||||
secrets: tuple[str, ...],
|
||||
python_version: Optional[str],
|
||||
gpu: bool = False,
|
||||
conda: tuple[str, ...] = (),
|
||||
conda_channels: tuple[str, ...] = (),
|
||||
):
|
||||
@@ -1141,17 +963,26 @@ class UdfDefinition:
|
||||
for key, value in environment.items()
|
||||
):
|
||||
raise TypeError("Function env keys and values must be strings")
|
||||
required_secrets = tuple(sorted(set(secrets)))
|
||||
invalid_secrets = [
|
||||
secret for secret in required_secrets if not _SECRET_NAME.fullmatch(secret)
|
||||
]
|
||||
if invalid_secrets:
|
||||
raise ValueError(f"invalid Function secret names: {invalid_secrets!r}")
|
||||
overlap = set(environment) & set(required_secrets)
|
||||
if overlap:
|
||||
raise ValueError(
|
||||
f"Function env and secret names must be disjoint: {sorted(overlap)!r}"
|
||||
)
|
||||
signature = _infer_signature(function, input_schema, output_schema)
|
||||
source = _package_source(function)
|
||||
digest = f"sha256:{hashlib.sha256(source).hexdigest()}"
|
||||
gpu_marker = _normalize_gpu_marker(gpu)
|
||||
runtime = PythonRuntimeSpec(
|
||||
kind="python_v2" if gpu_marker is not None else "python",
|
||||
kind="python",
|
||||
python_version=python_version
|
||||
or f"{sys.version_info.major}.{sys.version_info.minor}",
|
||||
environment=environment_spec,
|
||||
env=environment,
|
||||
gpu=gpu_marker,
|
||||
)
|
||||
self._function = function
|
||||
self._request = FunctionRegistrationRequest(
|
||||
@@ -1171,14 +1002,65 @@ class UdfDefinition:
|
||||
),
|
||||
signature=signature,
|
||||
runtime=runtime,
|
||||
required_secrets=required_secrets,
|
||||
)
|
||||
functools.update_wrapper(self, function)
|
||||
|
||||
@property
|
||||
def registration_request(self) -> FunctionRegistrationRequest:
|
||||
"""The immutable request sent by ``create_function_async``."""
|
||||
"""The immutable, value-free client model for a Function submission."""
|
||||
return self._request
|
||||
|
||||
def _submission_json(self, secrets: Optional[Mapping[str, str]]) -> str:
|
||||
"""Build one registration submission without retaining values on self."""
|
||||
if secrets is None:
|
||||
secret_values: Mapping[str, str] = {}
|
||||
elif not isinstance(secrets, Mapping):
|
||||
raise TypeError("Function secrets must be a mapping of names to strings")
|
||||
else:
|
||||
secret_values = secrets
|
||||
|
||||
if any(not isinstance(name, str) for name in secret_values):
|
||||
raise TypeError("Function secret names must be strings")
|
||||
expected = set(self._request.required_secrets)
|
||||
provided = set(secret_values)
|
||||
if provided != expected:
|
||||
missing = sorted(expected - provided)
|
||||
unexpected = sorted(provided - expected)
|
||||
details = []
|
||||
if missing:
|
||||
details.append(f"missing: {missing!r}")
|
||||
if unexpected:
|
||||
details.append(f"unexpected: {unexpected!r}")
|
||||
raise ValueError(
|
||||
"Function secret values must exactly match the declared secrets ("
|
||||
+ "; ".join(details)
|
||||
+ ")"
|
||||
)
|
||||
|
||||
canonical_values = {}
|
||||
total_bytes = 0
|
||||
for name in sorted(secret_values):
|
||||
value = _validate_secret_value(name, secret_values[name])
|
||||
total_bytes += len(value.encode("utf-8"))
|
||||
if total_bytes > _MAX_FUNCTION_SECRET_VALUES_BYTES:
|
||||
raise ValueError(
|
||||
"Function secret values exceed the "
|
||||
f"{_MAX_FUNCTION_SECRET_VALUES_BYTES}-byte request limit"
|
||||
)
|
||||
canonical_values[name] = value
|
||||
|
||||
submission = self._request._known_dict()
|
||||
if canonical_values:
|
||||
submission["secret_values"] = canonical_values
|
||||
return json.dumps(
|
||||
submission,
|
||||
ensure_ascii=False,
|
||||
allow_nan=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
|
||||
def __call__(self, *args, **kwargs):
|
||||
return self._function(*args, **kwargs)
|
||||
|
||||
@@ -1196,8 +1078,8 @@ def udf(
|
||||
output_schema: Optional[pa.DataType | pa.Field | pa.Schema] = None,
|
||||
pip: tuple[str, ...] | list[str] = (),
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
secrets: tuple[str, ...] | list[str] = (),
|
||||
python_version: Optional[str] = None,
|
||||
gpu: bool = False,
|
||||
conda: tuple[str, ...] | list[str] = (),
|
||||
conda_channels: tuple[str, ...] | list[str] = (),
|
||||
) -> Callable[[Callable[..., Any]], UdfDefinition]: ...
|
||||
@@ -1211,8 +1093,8 @@ def udf(
|
||||
output_schema: Optional[pa.DataType | pa.Field | pa.Schema] = None,
|
||||
pip: tuple[str, ...] | list[str] = (),
|
||||
env: Optional[Mapping[str, str]] = None,
|
||||
secrets: tuple[str, ...] | list[str] = (),
|
||||
python_version: Optional[str] = None,
|
||||
gpu: bool = False,
|
||||
conda: tuple[str, ...] | list[str] = (),
|
||||
conda_channels: tuple[str, ...] | list[str] = (),
|
||||
):
|
||||
@@ -1242,13 +1124,12 @@ def udf(
|
||||
conda_channels : sequence of str, optional
|
||||
Conda channels in priority order; requires ``conda``.
|
||||
env : mapping of str to str, optional
|
||||
Environment variables included in the Function definition.
|
||||
Non-secret environment variables. Use ``secrets`` for credentials.
|
||||
secrets : sequence of str, optional
|
||||
Names of secrets required by the callable. Supply their values separately
|
||||
to ``create_function`` or ``create_function_async``.
|
||||
python_version : str, optional
|
||||
Remote Python major/minor version. Defaults to the client version.
|
||||
gpu : bool, default False
|
||||
Whether every remote execution requires a GPU. The execution platform
|
||||
selects one compatible GPU for each worker. The requirement is part of
|
||||
the immutable Function version.
|
||||
|
||||
The packaged artifact is a snapshot: the function source plus exactly
|
||||
the module-level names it references (modules as imports, importable
|
||||
@@ -1268,16 +1149,15 @@ def udf(
|
||||
Examples
|
||||
--------
|
||||
>>> from lancedb import udf
|
||||
>>> @udf(pip=["numpy==2.2.0"])
|
||||
>>> @udf(pip=["numpy==2.2.0"], secrets=["MODEL_TOKEN"])
|
||||
... def score(value: float) -> float:
|
||||
... return value * 2
|
||||
>>> score(1.5)
|
||||
3.0
|
||||
>>> @udf(pip=["cupy-cuda12x"], gpu=True)
|
||||
... def gpu_score(value: int) -> int:
|
||||
... return value * 2
|
||||
>>> gpu_score.registration_request.runtime.gpu
|
||||
True
|
||||
>>> db.create_function( # doctest: +SKIP
|
||||
... score, secrets={"MODEL_TOKEN": "user-secret-value"}
|
||||
... )
|
||||
|
||||
"""
|
||||
|
||||
def decorate(target: Callable[..., Any]) -> UdfDefinition:
|
||||
@@ -1288,8 +1168,8 @@ def udf(
|
||||
output_schema=output_schema,
|
||||
pip=tuple(pip),
|
||||
env={} if env is None else env,
|
||||
secrets=tuple(secrets),
|
||||
python_version=python_version,
|
||||
gpu=gpu,
|
||||
conda=tuple(conda),
|
||||
conda_channels=tuple(conda_channels),
|
||||
)
|
||||
|
||||
@@ -7,7 +7,7 @@ import json
|
||||
import logging
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Mapping, Optional, Union
|
||||
from urllib.parse import urlparse
|
||||
import warnings
|
||||
|
||||
@@ -742,8 +742,15 @@ class RemoteDBConnection(DBConnection):
|
||||
return Job(self._conn.job(job_id))
|
||||
|
||||
@override
|
||||
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]:
|
||||
return Job(LOOP.run(self._conn.create_function_async(definition)))
|
||||
def create_function_async(
|
||||
self,
|
||||
definition: UdfDefinition,
|
||||
*,
|
||||
secrets: Optional[Mapping[str, str]] = None,
|
||||
) -> Job[FunctionVersion]:
|
||||
return Job(
|
||||
LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
|
||||
)
|
||||
|
||||
@override
|
||||
def get_function(self, name: str, *, version: str) -> FunctionVersion:
|
||||
|
||||
@@ -67,15 +67,7 @@ from ..query import (
|
||||
LanceTakeQueryBuilder,
|
||||
LanceVectorQueryBuilder,
|
||||
)
|
||||
from ..table import (
|
||||
AsyncTable,
|
||||
BlobMode,
|
||||
Branches,
|
||||
IndexStatistics,
|
||||
Query,
|
||||
Table,
|
||||
Tags,
|
||||
)
|
||||
from ..table import AsyncTable, BlobMode, Branches, IndexStatistics, Query, Table, Tags
|
||||
from ..types import BaseTokenizerType
|
||||
|
||||
|
||||
|
||||
@@ -2165,11 +2165,9 @@ class Table(ABC):
|
||||
Function columns are supported only on LanceDB Cloud and
|
||||
Enterprise.
|
||||
computed: Dict[str, str], optional
|
||||
A mapping from output column names to SQL expressions derives each
|
||||
output field from its expression. A direct projection of a Blob v2
|
||||
field inherits Blob v2 semantics; other expressions derive their
|
||||
ordinary Arrow type. Mapping order is declaration and dependency
|
||||
order.
|
||||
A map of column name to a SQL expression defining the column. The
|
||||
column's type and inputs are derived from the expression, so no
|
||||
data type is supplied.
|
||||
|
||||
Unlike ``transforms``, the expression is stored rather than
|
||||
evaluated now: the column is committed with no values, and rows get
|
||||
@@ -6270,11 +6268,8 @@ class AsyncTable:
|
||||
Function columns are supported only on LanceDB Cloud and
|
||||
Enterprise.
|
||||
computed: Dict[str, str], optional
|
||||
A mapping from output column names to SQL expressions derives each
|
||||
output field from its expression. A direct projection of a Blob v2
|
||||
field inherits Blob v2 semantics; other expressions derive their
|
||||
ordinary Arrow type. Mapping order is declaration and dependency
|
||||
order.
|
||||
A map of column name to a SQL expression defining the column. The
|
||||
column's type and inputs are derived from the expression.
|
||||
|
||||
Unlike ``transforms``, the expression is stored rather than
|
||||
evaluated now: the column is committed with no values, and rows get
|
||||
|
||||
@@ -37,6 +37,21 @@ def job_result(name: str) -> dict:
|
||||
return json.loads(fixture(name))["result"]
|
||||
|
||||
|
||||
def assert_no_secret_values(value):
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
assert key not in {
|
||||
"secret_value",
|
||||
"secret_values",
|
||||
"resolved_secret",
|
||||
"resolved_secrets",
|
||||
}
|
||||
assert_no_secret_values(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
assert_no_secret_values(child)
|
||||
|
||||
|
||||
def test_public_function_values_are_in_api_reference():
|
||||
docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md"
|
||||
rendered = docs.read_text()
|
||||
@@ -94,6 +109,7 @@ def test_function_version_identity_is_immutable_and_exact():
|
||||
version = FunctionVersion.from_json(json.dumps(value))
|
||||
assert version.name == "embed"
|
||||
assert version.version == "fv_01K3EXACT"
|
||||
assert version.required_secrets == ("HF_TOKEN",)
|
||||
|
||||
with pytest.raises((TypeError, ValueError)):
|
||||
version.version = "fv_changed"
|
||||
@@ -276,6 +292,15 @@ def test_refresh_result_rejects_non_u64_values(field):
|
||||
RefreshColumnResult.from_json(json.dumps(value))
|
||||
|
||||
|
||||
def test_canonical_client_values_contain_secret_names_only():
|
||||
version = FunctionVersion.from_json(
|
||||
json.dumps(job_result("remote_function_job.json"))
|
||||
)
|
||||
canonical = json.loads(version.to_canonical_json())
|
||||
assert canonical["required_secrets"] == ["HF_TOKEN"]
|
||||
assert_no_secret_values(canonical)
|
||||
|
||||
|
||||
class _FunctionDeclarationInner:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
@@ -20,10 +20,10 @@ import pytest
|
||||
|
||||
import lancedb
|
||||
from lancedb.functions import (
|
||||
PythonRuntimeSpec,
|
||||
_MAX_FUNCTION_SECRET_VALUE_BYTES,
|
||||
_MAX_FUNCTION_SECRET_VALUES_BYTES,
|
||||
FunctionRegistrationRequest,
|
||||
UdfDefinition,
|
||||
_canonical_arrow_type,
|
||||
_GRAMMAR_PRIMITIVES,
|
||||
udf,
|
||||
)
|
||||
|
||||
@@ -45,12 +45,28 @@ FIXTURES = (
|
||||
@udf(
|
||||
pip=["numpy>=2"],
|
||||
env={"MODE": "test"},
|
||||
secrets=["API_TOKEN"],
|
||||
python_version="3.12",
|
||||
)
|
||||
def normalize_score(value: float) -> float:
|
||||
return value / 100.0
|
||||
|
||||
|
||||
def _assert_no_secret_values(value):
|
||||
if isinstance(value, dict):
|
||||
for key, child in value.items():
|
||||
assert key not in {
|
||||
"secret_value",
|
||||
"secret_values",
|
||||
"resolved_secret",
|
||||
"resolved_secrets",
|
||||
}
|
||||
_assert_no_secret_values(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
_assert_no_secret_values(child)
|
||||
|
||||
|
||||
def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
|
||||
assert isinstance(normalize_score, UdfDefinition)
|
||||
assert normalize_score(25.0) == 0.25
|
||||
@@ -65,6 +81,8 @@ def test_scalar_udf_matches_shared_registration_golden_and_remains_callable():
|
||||
"kind": "scalar_to_arrow_batch",
|
||||
"version": 1,
|
||||
}
|
||||
assert request["required_secrets"] == ["API_TOKEN"]
|
||||
_assert_no_secret_values(request)
|
||||
|
||||
|
||||
def _run_packaged(definition, *args):
|
||||
@@ -95,58 +113,6 @@ def test_udf_conda_environment():
|
||||
udf(name="channels", conda_channels=["conda-forge"])(lambda value: value)
|
||||
|
||||
|
||||
def test_udf_gpu_marker_uses_gpu_runtime():
|
||||
@udf(pip=["cupy-cuda12x"], gpu=True)
|
||||
def double_on_gpu(value: int) -> int:
|
||||
return value * 2
|
||||
|
||||
request = json.loads(double_on_gpu.registration_request.to_canonical_json())
|
||||
assert request["runtime"]["kind"] == "python_v2"
|
||||
assert request["runtime"]["gpu"] is True
|
||||
|
||||
@udf(pip=["pyarrow"])
|
||||
def cpu_function(value: int) -> int:
|
||||
return value
|
||||
|
||||
cpu_runtime = json.loads(cpu_function.registration_request.to_canonical_json())[
|
||||
"runtime"
|
||||
]
|
||||
assert cpu_runtime["kind"] == "python"
|
||||
assert "gpu" not in cpu_runtime
|
||||
|
||||
def identity(value: int) -> int:
|
||||
return value
|
||||
|
||||
for invalid in [None, 0, 1, -1, 1.5, "", "true", "1", "H100"]:
|
||||
with pytest.raises(ValueError, match="gpu must be a boolean"):
|
||||
udf(name="invalid_gpu", gpu=invalid)(identity)
|
||||
|
||||
base_runtime = {
|
||||
"kind": "python_v2",
|
||||
"python_version": "3.12",
|
||||
"environment": {"kind": "pip"},
|
||||
}
|
||||
runtime = PythonRuntimeSpec.model_validate({**base_runtime, "gpu": True})
|
||||
assert runtime.gpu is True
|
||||
for invalid in [False, 1, 0, "", "true", "1", "H100"]:
|
||||
with pytest.raises(ValueError, match="runtime.gpu must be true"):
|
||||
PythonRuntimeSpec.model_validate({**base_runtime, "gpu": invalid})
|
||||
|
||||
|
||||
def test_unknown_runtime_discards_payload_before_known_field_validation():
|
||||
for payload in [
|
||||
{"kind": "python_v3", "gpu": {"model": "H100"}},
|
||||
{"kind": "python_v3", "resources": []},
|
||||
{
|
||||
"kind": "python_v3",
|
||||
"environment": {"kind": []},
|
||||
"python_version": 3.15,
|
||||
},
|
||||
]:
|
||||
runtime = PythonRuntimeSpec.model_validate(payload)
|
||||
assert runtime.to_canonical_json() == '{"kind":"python_v3"}'
|
||||
|
||||
|
||||
def test_udf_packages_attribute_access_and_body_imports():
|
||||
@udf
|
||||
def word_norm(body: str) -> float:
|
||||
@@ -226,7 +192,9 @@ def test_udf_resolves_module_globals_before_builtins(tmp_path):
|
||||
udf(module.uses_callable_shadow)
|
||||
|
||||
|
||||
def test_canonical_arrow_type_prefers_the_compact_grammar():
|
||||
def test_canonical_arrow_type_is_exactly_the_grammar():
|
||||
from lancedb.functions import _GRAMMAR_PRIMITIVES, _canonical_arrow_type
|
||||
|
||||
golden = json.loads(
|
||||
(
|
||||
Path(__file__).parents[3]
|
||||
@@ -237,19 +205,14 @@ def test_canonical_arrow_type_prefers_the_compact_grammar():
|
||||
case["arrow_type"] for case in golden["valid"] if "<" not in case["arrow_type"]
|
||||
]
|
||||
assert [name for _, name in _GRAMMAR_PRIMITIVES] == primitives
|
||||
assert _canonical_arrow_type(pa.list_(pa.field("item", pa.float32(), False))) == (
|
||||
"list<float32>"
|
||||
)
|
||||
assert (
|
||||
_canonical_arrow_type(pa.large_list(pa.field("item", pa.float32(), False)))
|
||||
== "large_list<float32>"
|
||||
)
|
||||
for outside in [
|
||||
pa.timestamp("us"),
|
||||
pa.decimal128(10, 2),
|
||||
pa.large_string(),
|
||||
pa.large_binary(),
|
||||
pa.binary(4),
|
||||
pa.duration("s"),
|
||||
pa.struct([pa.field("a", pa.int32())]),
|
||||
pa.list_(pa.float32(), 0),
|
||||
pa.list_(pa.timestamp("us")),
|
||||
]:
|
||||
@@ -433,33 +396,21 @@ def test_udf_recursion_versus_a_rebound_module_name(tmp_path):
|
||||
output_schema=None,
|
||||
pip=(),
|
||||
env={},
|
||||
secrets=(),
|
||||
python_version=None,
|
||||
)
|
||||
with pytest.raises(ValueError, match="binds that name to another value"):
|
||||
udf(raw_fact)
|
||||
|
||||
|
||||
def test_canonical_arrow_type_uses_exact_json_for_list_child_properties():
|
||||
nullable = pa.list_(pa.float32())
|
||||
assert json.loads(_canonical_arrow_type(nullable)) == {
|
||||
"type": "list",
|
||||
"fields": [
|
||||
{
|
||||
"name": "item",
|
||||
"nullable": True,
|
||||
"type": {"type": "float32"},
|
||||
}
|
||||
],
|
||||
}
|
||||
named = pa.list_(pa.field("custom", pa.float32(), nullable=False))
|
||||
assert json.loads(_canonical_arrow_type(named))["fields"][0]["name"] == "custom"
|
||||
def test_canonical_arrow_type_rejects_unrepresentable_list_children():
|
||||
from lancedb.functions import _canonical_arrow_type
|
||||
|
||||
for outside in [
|
||||
pa.list_(pa.float32()), # pyarrow default: nullable child
|
||||
pa.list_(pa.field("custom", pa.float32(), nullable=False)),
|
||||
pa.list_(pa.field("item", pa.float32(), nullable=False, metadata={"k": "v"})),
|
||||
pa.list_(pa.field("item", pa.float32(), nullable=False), 0),
|
||||
pa.list_(
|
||||
pa.field("item", pa.float32(), nullable=False, metadata={"k": "v"}), 3
|
||||
),
|
||||
pa.list_(pa.field("custom", pa.float32(), nullable=False), 3),
|
||||
]:
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
_canonical_arrow_type(outside)
|
||||
@@ -469,29 +420,6 @@ def test_canonical_arrow_type_uses_exact_json_for_list_child_properties():
|
||||
)
|
||||
== "fixed_size_list<float32, 3>"
|
||||
)
|
||||
fixed = json.loads(_canonical_arrow_type(pa.list_(pa.float32(), 3)))
|
||||
assert fixed == {
|
||||
"type": "fixed_size_list",
|
||||
"fields": [
|
||||
{
|
||||
"name": "item",
|
||||
"nullable": True,
|
||||
"type": {"type": "float32"},
|
||||
}
|
||||
],
|
||||
"length": 3,
|
||||
}
|
||||
large = json.loads(_canonical_arrow_type(pa.large_list(pa.float32())))
|
||||
assert large["type"] == "large_list"
|
||||
assert large["fields"][0]["nullable"] is True
|
||||
|
||||
for invalid_struct in [
|
||||
pa.struct([]),
|
||||
pa.struct([pa.field("a", pa.int32()), pa.field("a", pa.int64())]),
|
||||
pa.struct([pa.field("", pa.int32())]),
|
||||
]:
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
_canonical_arrow_type(invalid_struct)
|
||||
|
||||
|
||||
def _calls_missing(value: int) -> int:
|
||||
@@ -529,7 +457,6 @@ def _arrow_type_from_golden(spec: dict) -> pa.DataType:
|
||||
"null": pa.null(),
|
||||
"bool": pa.bool_(),
|
||||
"utf8": pa.string(),
|
||||
"large_utf8": pa.large_string(),
|
||||
"binary": pa.binary(),
|
||||
"float16": pa.float16(),
|
||||
"float32": pa.float32(),
|
||||
@@ -546,6 +473,8 @@ def test_arrow_type_grammar_matches_the_shared_golden():
|
||||
/ "rust/lancedb/tests/fixtures/first_class_functions/v1/arrow_types.json"
|
||||
).read_text()
|
||||
)
|
||||
from lancedb.functions import _canonical_arrow_type
|
||||
|
||||
emitted = {
|
||||
case["arrow_type"]: _canonical_arrow_type(_arrow_type_from_golden(case["json"]))
|
||||
for case in golden["valid"]
|
||||
@@ -578,250 +507,6 @@ def test_explicit_arrow_schema_is_deterministic():
|
||||
assert signature.output.nullable is False
|
||||
|
||||
|
||||
def test_blob_fields_use_the_scalar_function_semantic_type():
|
||||
@udf(
|
||||
input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
|
||||
output_schema=lancedb.blob("result", nullable=False),
|
||||
)
|
||||
def copy_blob(image):
|
||||
return image
|
||||
|
||||
signature = copy_blob.registration_request.signature
|
||||
assert signature.inputs[0].arrow_type == "blob_v2"
|
||||
assert signature.output.kind == "scalar"
|
||||
assert signature.output.arrow_type == "blob_v2"
|
||||
|
||||
|
||||
def test_named_struct_function_can_include_a_blob_result_field():
|
||||
@udf(
|
||||
input_schema=pa.schema([lancedb.blob("image", nullable=False)]),
|
||||
output_schema=pa.schema(
|
||||
[
|
||||
lancedb.blob("thumbnail", nullable=False),
|
||||
pa.field("width", pa.int32(), nullable=False),
|
||||
]
|
||||
),
|
||||
)
|
||||
def inspect_blob(image):
|
||||
return {"thumbnail": image, "width": 1}
|
||||
|
||||
output = inspect_blob.registration_request.signature.output
|
||||
assert output.kind == "named_struct"
|
||||
assert [(field.name, field.arrow_type) for field in output.fields] == [
|
||||
("thumbnail", "blob_v2"),
|
||||
("width", "int32"),
|
||||
]
|
||||
|
||||
|
||||
def test_metadata_marked_blob_field_uses_the_semantic_type():
|
||||
extension = lancedb.blob("image", nullable=False).type
|
||||
storage = (
|
||||
extension.storage_type if isinstance(extension, pa.ExtensionType) else extension
|
||||
)
|
||||
metadata_blob = pa.field(
|
||||
"image",
|
||||
storage,
|
||||
nullable=False,
|
||||
metadata={"ARROW:extension:name": "lance.blob.v2"},
|
||||
)
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([metadata_blob]),
|
||||
output_schema=pa.field("size", pa.int64(), nullable=False),
|
||||
)
|
||||
def blob_size(image):
|
||||
return len(image)
|
||||
|
||||
assert blob_size.registration_request.signature.inputs[0].arrow_type == "blob_v2"
|
||||
|
||||
|
||||
def test_blob_marker_rejects_invalid_storage_layout():
|
||||
malformed = pa.field(
|
||||
"image",
|
||||
pa.int64(),
|
||||
nullable=False,
|
||||
metadata={"ARROW:extension:name": "lance.blob.v2"},
|
||||
)
|
||||
|
||||
with pytest.raises(TypeError, match="requires a supported Blob storage layout"):
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([malformed]),
|
||||
output_schema=pa.field("size", pa.int64(), nullable=False),
|
||||
)
|
||||
def blob_size(image):
|
||||
return len(image)
|
||||
|
||||
|
||||
def test_nested_blob_signature_field_has_a_clear_error():
|
||||
nested = pa.field(
|
||||
"value",
|
||||
pa.struct([lancedb.blob("image", nullable=False)]),
|
||||
nullable=False,
|
||||
)
|
||||
with pytest.raises(TypeError, match="nested Blob v2 fields are not supported"):
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([nested]),
|
||||
output_schema=pa.field("size", pa.int64(), nullable=False),
|
||||
)
|
||||
def blob_size(value):
|
||||
return len(value["image"])
|
||||
|
||||
|
||||
def test_nested_non_blob_extension_is_not_silently_unwrapped():
|
||||
class TestExtension(pa.ExtensionType):
|
||||
def __init__(self):
|
||||
super().__init__(pa.int64(), "test.function.extension")
|
||||
|
||||
def __arrow_ext_serialize__(self):
|
||||
return b""
|
||||
|
||||
@classmethod
|
||||
def __arrow_ext_deserialize__(cls, storage_type, serialized):
|
||||
return cls()
|
||||
|
||||
nested = pa.field(
|
||||
"value",
|
||||
pa.struct([pa.field("extended", TestExtension(), nullable=False)]),
|
||||
nullable=False,
|
||||
)
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([nested]),
|
||||
output_schema=pa.field("result", pa.int64(), nullable=False),
|
||||
)
|
||||
def extension_value(value):
|
||||
return value["extended"]
|
||||
|
||||
|
||||
def test_explicit_large_utf8_schemas_use_the_canonical_function_name():
|
||||
input_schema = pa.schema([pa.field("text", pa.large_string(), nullable=True)])
|
||||
output_schema = pa.field("result", pa.large_string(), nullable=False)
|
||||
|
||||
@udf(input_schema=input_schema, output_schema=output_schema)
|
||||
def preserve(text):
|
||||
return text
|
||||
|
||||
signature = preserve.registration_request.signature
|
||||
assert signature.inputs[0].arrow_type == "large_utf8"
|
||||
assert signature.inputs[0].nullable is True
|
||||
assert signature.output.arrow_type == "large_utf8"
|
||||
assert signature.output.nullable is False
|
||||
|
||||
nested = pa.struct([pa.field("text", pa.large_string(), nullable=True)])
|
||||
assert json.loads(_canonical_arrow_type(nested)) == {
|
||||
"type": "struct",
|
||||
"fields": [
|
||||
{
|
||||
"name": "text",
|
||||
"nullable": True,
|
||||
"type": {"type": "large_utf8"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_nested_struct_output_uses_canonical_exact_json():
|
||||
token = pa.struct(
|
||||
[
|
||||
pa.field("position", pa.int32(), nullable=False),
|
||||
pa.field("value", pa.string(), nullable=False),
|
||||
pa.field("length", pa.int32(), nullable=False),
|
||||
]
|
||||
)
|
||||
analysis = pa.struct(
|
||||
[
|
||||
pa.field("normalized_text", pa.string(), nullable=False),
|
||||
pa.field("has_content", pa.bool_(), nullable=False),
|
||||
pa.field(
|
||||
"metrics",
|
||||
pa.struct(
|
||||
[
|
||||
pa.field("character_count", pa.int64(), nullable=False),
|
||||
pa.field("word_count", pa.int32(), nullable=False),
|
||||
pa.field("average_word_length", pa.float64(), nullable=False),
|
||||
]
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
pa.field(
|
||||
"diagnostics",
|
||||
pa.struct(
|
||||
[
|
||||
pa.field("status", pa.string(), nullable=False),
|
||||
pa.field(
|
||||
"normalization",
|
||||
pa.struct(
|
||||
[
|
||||
pa.field("changed", pa.bool_(), nullable=False),
|
||||
pa.field(
|
||||
"original_length", pa.int64(), nullable=False
|
||||
),
|
||||
]
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
]
|
||||
),
|
||||
nullable=False,
|
||||
),
|
||||
pa.field(
|
||||
"token_preview",
|
||||
pa.list_(pa.field("item", token, nullable=False)),
|
||||
nullable=False,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([pa.field("text", pa.string(), nullable=False)]),
|
||||
output_schema=pa.field("analysis", analysis, nullable=False),
|
||||
)
|
||||
def analyze(text):
|
||||
return {"normalized_text": text}
|
||||
|
||||
output = analyze.registration_request.signature.output
|
||||
assert output.kind == "named_struct"
|
||||
assert [field.name for field in output.fields] == [
|
||||
"normalized_text",
|
||||
"has_content",
|
||||
"metrics",
|
||||
"diagnostics",
|
||||
"token_preview",
|
||||
]
|
||||
metrics = json.loads(output.fields[2].arrow_type)
|
||||
assert metrics == {
|
||||
"type": "struct",
|
||||
"fields": [
|
||||
{
|
||||
"name": "character_count",
|
||||
"nullable": False,
|
||||
"type": {"type": "int64"},
|
||||
},
|
||||
{
|
||||
"name": "word_count",
|
||||
"nullable": False,
|
||||
"type": {"type": "int32"},
|
||||
},
|
||||
{
|
||||
"name": "average_word_length",
|
||||
"nullable": False,
|
||||
"type": {"type": "float64"},
|
||||
},
|
||||
],
|
||||
}
|
||||
preview = json.loads(output.fields[4].arrow_type)
|
||||
assert preview["type"] == "list"
|
||||
assert preview["fields"][0]["type"]["type"] == "struct"
|
||||
assert [field["name"] for field in preview["fields"][0]["type"]["fields"]] == [
|
||||
"position",
|
||||
"value",
|
||||
"length",
|
||||
]
|
||||
|
||||
|
||||
def test_annotation_and_explicit_schema_validation_fail_closed():
|
||||
with pytest.raises(TypeError, match="missing Function annotations"):
|
||||
|
||||
@@ -865,80 +550,55 @@ def test_annotation_and_explicit_schema_validation_fail_closed():
|
||||
def nullable_explicit(value):
|
||||
return value
|
||||
|
||||
for invalid_field in [
|
||||
pa.field("", pa.int32(), nullable=False),
|
||||
pa.field("result", pa.int32(), nullable=False, metadata={"k": "v"}),
|
||||
]:
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([pa.field("value", pa.int64())]),
|
||||
output_schema=pa.schema([invalid_field]),
|
||||
)
|
||||
def invalid_explicit_field(value):
|
||||
return value
|
||||
def test_secret_names_are_canonical_and_disjoint_from_environment():
|
||||
@udf(secrets=["Z_TOKEN", "A_TOKEN", "Z_TOKEN"])
|
||||
def canonical_secrets(value: int) -> int:
|
||||
return value
|
||||
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
assert canonical_secrets.registration_request.required_secrets == (
|
||||
"A_TOKEN",
|
||||
"Z_TOKEN",
|
||||
)
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema(
|
||||
[pa.field("value", pa.int64(), metadata={"k": "v"})]
|
||||
),
|
||||
output_schema=pa.int64(),
|
||||
)
|
||||
def input_field_metadata(value):
|
||||
with pytest.raises(ValueError, match="must be disjoint"):
|
||||
|
||||
@udf(env={"TOKEN": "plaintext"}, secrets=["TOKEN"])
|
||||
def overlapping(value: int) -> int:
|
||||
return value
|
||||
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([pa.field("value", pa.int64())]),
|
||||
output_schema=pa.field(
|
||||
"result", pa.int64(), nullable=False, metadata={"k": "v"}
|
||||
),
|
||||
)
|
||||
def scalar_output_field_metadata(value):
|
||||
return value
|
||||
def test_declared_secret_api_still_requires_explicit_create_values():
|
||||
@udf(secrets=["API_TOKEN"])
|
||||
def declared_secret(value: int) -> int:
|
||||
return value
|
||||
|
||||
struct_type = pa.struct([pa.field("value", pa.int64(), nullable=False)])
|
||||
with pytest.raises(TypeError, match="unsupported Arrow type"):
|
||||
with pytest.raises(ValueError, match="missing"):
|
||||
declared_secret._submission_json(None)
|
||||
submission = json.loads(
|
||||
declared_secret._submission_json({"API_TOKEN": "explicit-secret"})
|
||||
)
|
||||
assert submission["required_secrets"] == ["API_TOKEN"]
|
||||
assert submission["secret_values"] == {"API_TOKEN": "explicit-secret"}
|
||||
|
||||
@udf(
|
||||
input_schema=pa.schema([pa.field("value", pa.int64())]),
|
||||
output_schema=pa.field(
|
||||
"result", struct_type, nullable=False, metadata={"k": "v"}
|
||||
),
|
||||
)
|
||||
def struct_output_field_metadata(value):
|
||||
return {"value": value}
|
||||
|
||||
for input_schema, output_schema in [
|
||||
(
|
||||
pa.schema([pa.field("value", pa.int64())], metadata={"k": "v"}),
|
||||
pa.int64(),
|
||||
),
|
||||
(
|
||||
pa.schema([pa.field("value", pa.int64())]),
|
||||
pa.schema(
|
||||
[pa.field("result", pa.int64(), nullable=False)],
|
||||
metadata={"k": "v"},
|
||||
),
|
||||
),
|
||||
]:
|
||||
with pytest.raises(TypeError, match="schema metadata"):
|
||||
def test_no_secrets_preserve_canonical_registration_shape():
|
||||
@udf
|
||||
def no_secrets(value: int) -> int:
|
||||
return value
|
||||
|
||||
@udf(input_schema=input_schema, output_schema=output_schema)
|
||||
def schema_metadata(value):
|
||||
return value
|
||||
canonical = json.loads(no_secrets.registration_request.to_canonical_json())
|
||||
assert "required_secrets" not in canonical
|
||||
assert json.loads(no_secrets._submission_json(None)) == canonical
|
||||
|
||||
|
||||
def test_local_function_catalog_operations_are_not_supported(tmp_path):
|
||||
db = lancedb.connect(tmp_path)
|
||||
message = "Function catalog operations are not supported by this database"
|
||||
with pytest.raises(NotImplementedError, match=message):
|
||||
db.create_function(normalize_score)
|
||||
db.create_function(normalize_score, secrets={"API_TOKEN": "value"})
|
||||
with pytest.raises(NotImplementedError, match=message):
|
||||
db.create_function_async(normalize_score)
|
||||
db.create_function_async(normalize_score, secrets={"API_TOKEN": "value"})
|
||||
with pytest.raises(NotImplementedError, match=message):
|
||||
db.get_function("normalize_score", version="fv_exact")
|
||||
|
||||
@@ -968,6 +628,7 @@ def _mock_remote_function_catalog():
|
||||
"runtime": body["runtime"],
|
||||
"runtime_digest": "sha256:runtime",
|
||||
"environment_digest": "sha256:environment",
|
||||
"required_secrets": body.get("required_secrets", []),
|
||||
"created_at": "2026-08-21T00:00:00Z",
|
||||
}
|
||||
response = {"job_id": "job-register"}
|
||||
@@ -1014,7 +675,9 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip():
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
registration = db.create_function_async(normalize_score)
|
||||
registration = db.create_function_async(
|
||||
normalize_score, secrets={"API_TOKEN": "secret-value"}
|
||||
)
|
||||
assert registration.id == "job-register"
|
||||
created = registration.wait()
|
||||
reopened = db.get_function("normalize_score", version=created.version)
|
||||
@@ -1023,9 +686,18 @@ def test_remote_registration_job_and_exact_version_reopen_round_trip():
|
||||
assert reopened.name == "normalize_score"
|
||||
assert reopened.version == "fv_exact"
|
||||
create_request = state["requests"][0][1]
|
||||
assert create_request == json.loads(
|
||||
expected = json.loads(normalize_score.registration_request.to_canonical_json())
|
||||
expected["secret_values"] = {"API_TOKEN": "secret-value"}
|
||||
assert create_request == expected
|
||||
durable_request = FunctionRegistrationRequest.from_json(json.dumps(create_request))
|
||||
assert not hasattr(durable_request, "secret_values")
|
||||
assert "secret_values" not in json.loads(durable_request.to_canonical_json())
|
||||
assert "secret_values" not in json.loads(
|
||||
normalize_score.registration_request.to_canonical_json()
|
||||
)
|
||||
assert "secret-value" not in repr(normalize_score)
|
||||
assert "secret-value" not in repr(normalize_score.registration_request)
|
||||
assert not hasattr(created, "secret_values")
|
||||
|
||||
|
||||
def test_blocking_remote_registration_returns_function_version():
|
||||
@@ -1036,7 +708,9 @@ def test_blocking_remote_registration_returns_function_version():
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
created = db.create_function(normalize_score)
|
||||
created = db.create_function(
|
||||
normalize_score, secrets={"API_TOKEN": "blocking-secret"}
|
||||
)
|
||||
|
||||
assert created.name == "normalize_score"
|
||||
assert created.version == "fv_exact"
|
||||
@@ -1044,3 +718,121 @@ def test_blocking_remote_registration_returns_function_version():
|
||||
"/v1/functions/create",
|
||||
"/v1/jobs/describe",
|
||||
]
|
||||
assert state["requests"][0][1]["secret_values"] == {"API_TOKEN": "blocking-secret"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("secret_values", "error_type", "message"),
|
||||
[
|
||||
(None, ValueError, "missing"),
|
||||
({}, ValueError, "missing"),
|
||||
({"OTHER": "value"}, ValueError, "missing.*unexpected"),
|
||||
({"API_TOKEN": ""}, ValueError, "non-empty"),
|
||||
({"API_TOKEN": "bad\0value"}, ValueError, "NUL"),
|
||||
({"API_TOKEN": 123}, TypeError, "must be a string"),
|
||||
([("API_TOKEN", "value")], TypeError, "must be a mapping"),
|
||||
],
|
||||
)
|
||||
def test_secret_values_are_validated_before_remote_request(
|
||||
secret_values, error_type, message
|
||||
):
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = lancedb.connect(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
with pytest.raises(error_type, match=message):
|
||||
db.create_function_async(normalize_score, secrets=secret_values)
|
||||
assert state["requests"] == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"x" * _MAX_FUNCTION_SECRET_VALUE_BYTES,
|
||||
"é" * (_MAX_FUNCTION_SECRET_VALUE_BYTES // len("é".encode("utf-8"))),
|
||||
],
|
||||
ids=["ascii", "multibyte"],
|
||||
)
|
||||
def test_secret_value_accepts_exact_utf8_byte_limit(value):
|
||||
submission = json.loads(normalize_score._submission_json({"API_TOKEN": value}))
|
||||
assert submission["secret_values"]["API_TOKEN"] == value
|
||||
assert len(value.encode("utf-8")) == _MAX_FUNCTION_SECRET_VALUE_BYTES
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
"x" * (_MAX_FUNCTION_SECRET_VALUE_BYTES + 1),
|
||||
"é" * (_MAX_FUNCTION_SECRET_VALUE_BYTES // len("é".encode("utf-8")) + 1),
|
||||
],
|
||||
ids=["ascii", "multibyte"],
|
||||
)
|
||||
def test_secret_value_rejects_over_utf8_byte_limit_before_json_construction(
|
||||
monkeypatch, value
|
||||
):
|
||||
def fail_if_json_construction_starts(self):
|
||||
pytest.fail("oversized secret reached JSON construction")
|
||||
|
||||
monkeypatch.setattr(
|
||||
FunctionRegistrationRequest, "_known_dict", fail_if_json_construction_starts
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"exceeds the 65536-byte limit"):
|
||||
normalize_score._submission_json({"API_TOKEN": value})
|
||||
|
||||
|
||||
def test_secret_values_accept_exact_aggregate_utf8_byte_limit(monkeypatch):
|
||||
names = tuple(f"SECRET_{index}" for index in range(8))
|
||||
value = "é" * (_MAX_FUNCTION_SECRET_VALUE_BYTES // len("é".encode("utf-8")))
|
||||
values = {name: value for name in names}
|
||||
monkeypatch.setattr(
|
||||
normalize_score,
|
||||
"_request",
|
||||
normalize_score._request._copy(update={"required_secrets": names}),
|
||||
)
|
||||
|
||||
submission = json.loads(normalize_score._submission_json(values))
|
||||
|
||||
assert submission["secret_values"] == values
|
||||
assert sum(len(item.encode("utf-8")) for item in values.values()) == (
|
||||
_MAX_FUNCTION_SECRET_VALUES_BYTES
|
||||
)
|
||||
|
||||
|
||||
def test_secret_values_reject_aggregate_over_limit_before_construction(monkeypatch):
|
||||
names = tuple(f"SECRET_{index}" for index in range(9))
|
||||
values = {name: "x" * _MAX_FUNCTION_SECRET_VALUE_BYTES for name in names}
|
||||
monkeypatch.setattr(
|
||||
normalize_score,
|
||||
"_request",
|
||||
normalize_score._request._copy(update={"required_secrets": names}),
|
||||
)
|
||||
|
||||
def fail_if_json_construction_starts(self):
|
||||
pytest.fail("oversized aggregate reached JSON construction")
|
||||
|
||||
monkeypatch.setattr(
|
||||
FunctionRegistrationRequest, "_known_dict", fail_if_json_construction_starts
|
||||
)
|
||||
with pytest.raises(ValueError, match=r"exceed.*524288-byte request limit"):
|
||||
normalize_score._submission_json(values)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_remote_registration_submits_secret_values_only_once():
|
||||
with _mock_remote_function_catalog() as (host, state):
|
||||
db = await lancedb.connect_async(
|
||||
"db://dev",
|
||||
api_key="fake",
|
||||
host_override=host,
|
||||
client_config={"retry_config": {"retries": 0}},
|
||||
)
|
||||
registration = await db.create_function_async(
|
||||
normalize_score, secrets={"API_TOKEN": "async-secret"}
|
||||
)
|
||||
created = await registration.wait()
|
||||
|
||||
assert state["requests"][0][1]["secret_values"] == {"API_TOKEN": "async-secret"}
|
||||
assert not hasattr(created, "secret_values")
|
||||
|
||||
@@ -4087,29 +4087,6 @@ def test_computed_column_rejects_transforms_and_computed_together(tmp_path):
|
||||
table.add_columns({"a": "x + 1"}, computed={"b": "x * 2"})
|
||||
|
||||
|
||||
def test_computed_column_blob_projection_inherits_semantics(tmp_path):
|
||||
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
|
||||
db = lancedb.connect(tmp_path)
|
||||
table = db.create_table("computed_column_blob", schema=schema)
|
||||
table.add(
|
||||
[
|
||||
{"id": 1, "image": b"hello"},
|
||||
{"id": 2, "image": b""},
|
||||
{"id": 3, "image": None},
|
||||
]
|
||||
)
|
||||
|
||||
table.add_columns(computed={"image_copy": "image", "second_copy": "image_copy"})
|
||||
assert table.refresh_column("image_copy").rows_filled == 2
|
||||
assert table.refresh_column("second_copy").rows_filled == 2
|
||||
assert table.blob_columns() == ["image", "image_copy", "second_copy"]
|
||||
|
||||
hits = table.search().with_row_id(True).limit(10).to_arrow()
|
||||
rows = sorted(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
|
||||
copied = table.fetch_blobs("second_copy", [row_id for _, row_id in rows])
|
||||
assert copied.to_pylist() == [b"hello", b"", None]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_computed_column_async(tmp_path):
|
||||
db = await lancedb.connect_async(tmp_path)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "lancedb"
|
||||
version = "0.38.0"
|
||||
version = "0.38.0-beta.11"
|
||||
edition.workspace = true
|
||||
description = "LanceDB: A serverless, low-latency vector database for AI applications"
|
||||
license.workspace = true
|
||||
|
||||
@@ -13,7 +13,7 @@ use lance::dataset::{ReadParams, WriteMode, builder::DatasetBuilder};
|
||||
use lance::io::{ObjectStore, ObjectStoreParams, WrappingObjectStore};
|
||||
use lance_datafusion::utils::StreamingWriteSource;
|
||||
use lance_file::version::LanceFileVersion;
|
||||
use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider};
|
||||
use lance_io::object_store::{ReadDirOptions, StorageOptionsAccessor, StorageOptionsProvider};
|
||||
use lance_table::io::commit::commit_handler_from_url;
|
||||
use object_store::local::LocalFileSystem;
|
||||
use snafu::ResultExt;
|
||||
@@ -282,11 +282,14 @@ impl std::fmt::Display for ListingDatabase {
|
||||
|
||||
const LANCE_EXTENSION: &str = "lance";
|
||||
|
||||
/// The table a listed child directory holds, or `None` if it is not a table at all.
|
||||
/// The table a listed child of the database names, or `None` if the child is not a table.
|
||||
///
|
||||
/// A table is the directory `<name>.lance`; a loose file or any other directory under the
|
||||
/// database prefix belongs to something else. `dir_suffix` is `.lance`, built once by the
|
||||
/// caller rather than per child.
|
||||
/// The table a listed child directory holds, or `None` if it is not a table at all.
|
||||
///
|
||||
/// Only directories are considered, so a loose object named like a table is not one.
|
||||
fn table_name(location: &object_store::path::Path, dir_suffix: &str) -> Option<String> {
|
||||
location
|
||||
.filename()?
|
||||
@@ -294,75 +297,6 @@ fn table_name(location: &object_store::path::Path, dir_suffix: &str) -> Option<S
|
||||
.map(String::from)
|
||||
.filter(|name| !name.is_empty())
|
||||
}
|
||||
|
||||
/// One page of the table directories under the database directory, in key order.
|
||||
struct DirPage {
|
||||
/// The table directories the page holds, as the store lists them.
|
||||
common_prefixes: Vec<object_store::path::Path>,
|
||||
/// Resumes after this page, or `None` when the page reached the end of the level.
|
||||
page_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Where a listed location sits inside the database directory — the space page tokens live
|
||||
/// in — or `None` if it is not a child of that directory at all. Matching both halves of the
|
||||
/// prefix drops a location that merely starts with the directory's name (`dbx/y` against
|
||||
/// `db/`) as well as the marker object some stores keep for the directory itself.
|
||||
fn relative_key<'a>(prefix: Option<&str>, location: &'a str) -> Option<&'a str> {
|
||||
let relative = match prefix {
|
||||
Some(prefix) => location.strip_prefix(prefix)?,
|
||||
None => location,
|
||||
};
|
||||
(!relative.is_empty()).then_some(relative)
|
||||
}
|
||||
|
||||
/// One page of the table directories under `base_path`, one directory level deep.
|
||||
///
|
||||
/// Lance 11 exposes no paginated directory listing, so the level is listed in full and paged
|
||||
/// locally: table directories go into key order (a directory's key keeps its trailing `/`,
|
||||
/// so a token is never a table name), the page is the smallest `limit` of them past
|
||||
/// `page_token`, and the token handed back is the key of the last directory the page took —
|
||||
/// so a page that took nothing ends the listing rather than resuming from a position no page
|
||||
/// ever reached. Only `<name>.lance/` directories enter the page: loose objects, other
|
||||
/// directories, and a bare `.lance/` never take a page slot or name a token, which keeps a
|
||||
/// page to exactly one listing of the level. Correct on every store, at the cost of that one
|
||||
/// full-level listing per page.
|
||||
async fn read_dir_page(
|
||||
object_store: &ObjectStore,
|
||||
base_path: &object_store::path::Path,
|
||||
page_token: Option<String>,
|
||||
limit: Option<usize>,
|
||||
) -> Result<DirPage> {
|
||||
let listed = object_store.list_with_delimiter(Some(base_path)).await?;
|
||||
let prefix = {
|
||||
let base = base_path.as_ref();
|
||||
(!base.is_empty()).then(|| format!("{base}/"))
|
||||
};
|
||||
let table_dir_suffix = format!(".{LANCE_EXTENSION}/");
|
||||
let mut children: Vec<(String, object_store::path::Path)> = listed
|
||||
.common_prefixes
|
||||
.into_iter()
|
||||
.filter_map(|location| {
|
||||
let key = format!("{}/", relative_key(prefix.as_deref(), location.as_ref())?);
|
||||
(key.len() > table_dir_suffix.len() && key.ends_with(&table_dir_suffix))
|
||||
.then_some((key, location))
|
||||
})
|
||||
.collect();
|
||||
children.sort_unstable_by(|(left, _), (right, _)| left.cmp(right));
|
||||
if let Some(resume) = &page_token {
|
||||
children.retain(|(key, _)| key > resume);
|
||||
}
|
||||
let total = children.len();
|
||||
children.truncate(limit.unwrap_or(total).min(total));
|
||||
let page_token = match children.last() {
|
||||
Some((last, _)) if children.len() < total => Some(last.clone()),
|
||||
_ => None,
|
||||
};
|
||||
Ok(DirPage {
|
||||
common_prefixes: children.into_iter().map(|(_, location)| location).collect(),
|
||||
page_token,
|
||||
})
|
||||
}
|
||||
|
||||
const ENGINE: &str = "engine";
|
||||
const MIRRORED_STORE: &str = "mirroredStore";
|
||||
|
||||
@@ -1048,7 +982,8 @@ impl Database for ListingDatabase {
|
||||
let mut tables = Vec::new();
|
||||
let mut page_token = request.page_token.filter(|token| !token.is_empty());
|
||||
|
||||
// A page of nothing: no table was handed over for a token to resume after.
|
||||
// A page of nothing: the store rejects a limit of zero, and no table was handed over
|
||||
// for a token to resume after.
|
||||
if limit == Some(0) {
|
||||
return Ok(ListTablesResponse {
|
||||
context: None,
|
||||
@@ -1057,21 +992,35 @@ impl Database for ListingDatabase {
|
||||
});
|
||||
}
|
||||
|
||||
// The page holds only table directories, so one call — and the one full-level
|
||||
// listing behind it — fills it.
|
||||
let page = read_dir_page(
|
||||
&self.object_store,
|
||||
&self.base_path,
|
||||
page_token.take(),
|
||||
limit,
|
||||
)
|
||||
.await?;
|
||||
page_token = page.page_token;
|
||||
tables.extend(
|
||||
page.common_prefixes
|
||||
.iter()
|
||||
.filter_map(|location| table_name(location, &dir_suffix)),
|
||||
);
|
||||
loop {
|
||||
// Ask only for what the page still has room for, so a database holding more
|
||||
// than one page costs one request per page rather than one per table.
|
||||
let listing = self
|
||||
.object_store
|
||||
.read_dir_page(
|
||||
self.base_path.clone(),
|
||||
ReadDirOptions {
|
||||
page_token: page_token.take(),
|
||||
limit: limit.map(|limit| limit - tables.len()),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
page_token = listing.page_token;
|
||||
// Only child directories can be tables, and the store already separates them
|
||||
// out, so the objects in the page are not looked at.
|
||||
tables.extend(
|
||||
listing
|
||||
.result
|
||||
.common_prefixes
|
||||
.iter()
|
||||
.filter_map(|location| table_name(location, &dir_suffix)),
|
||||
);
|
||||
// Children that are not tables leave the page short of the limit, so keep
|
||||
// going until the page is full or the database runs out.
|
||||
if page_token.is_none() || limit.is_none_or(|limit| tables.len() >= limit) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ListTablesResponse {
|
||||
context: None,
|
||||
@@ -1717,8 +1666,8 @@ mod tests {
|
||||
}
|
||||
|
||||
/// Only directories named `<name>.lance` are tables; loose files and other directories
|
||||
/// under the database prefix are not. They never take a page slot, so even a `limit`
|
||||
/// smaller than the clutter ahead of the first table returns that table.
|
||||
/// under the database prefix are not. A page spent on them is filled from the next one,
|
||||
/// so a page holding only non-tables does not read as an empty database.
|
||||
#[tokio::test]
|
||||
async fn test_listing_ignores_non_table_children() {
|
||||
let (tempdir, db) = setup_database().await;
|
||||
@@ -1737,37 +1686,6 @@ mod tests {
|
||||
assert_eq!(page.tables, vec!["real"]);
|
||||
}
|
||||
|
||||
/// The Lance 11 fallback pages locally over one full-level listing, so a bounded page
|
||||
/// costs exactly one listing call — clutter ahead of the first table must not buy extra
|
||||
/// round trips.
|
||||
#[tokio::test]
|
||||
async fn test_one_full_listing_per_public_page() {
|
||||
use crate::io::object_store::io_tracking::IoStatsHolder;
|
||||
use lance_io::object_store::WrappingObjectStore;
|
||||
|
||||
let (tempdir, mut db) = setup_database().await;
|
||||
create_tables(&db, &["real"]).await;
|
||||
std::fs::write(tempdir.path().join("aaa-loose.lance"), b"not a table").unwrap();
|
||||
create_dir_all(tempdir.path().join("aaa-scratch")).unwrap();
|
||||
|
||||
let io_stats = IoStatsHolder::default();
|
||||
let mut tracked_store = (*db.object_store).clone();
|
||||
tracked_store.inner =
|
||||
io_stats.wrap(&tracked_store.store_prefix, tracked_store.inner.clone());
|
||||
db.object_store = Arc::new(tracked_store);
|
||||
|
||||
let page = db
|
||||
.list_tables(ListTablesRequest {
|
||||
limit: Some(1),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(page.tables, vec!["real"]);
|
||||
assert_eq!(io_stats.incremental_stats().read_iops, 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn listing_ignores_empty_table_name() {
|
||||
let (tempdir, db) = setup_database().await;
|
||||
|
||||
+335
-156
@@ -5,9 +5,9 @@
|
||||
//! backend-neutral terminal result of a computed-column refresh.
|
||||
//!
|
||||
//! This module contains client/wire values only. Catalog persistence,
|
||||
//! environment bake, and execution are owned by Sophon.
|
||||
//! environment bake, secret resolution, and execution are owned by Sophon.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use serde::de::{self, DeserializeOwned};
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
@@ -15,8 +15,15 @@ use serde_json::Value;
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
/// Semantic Function type for a Blob v2 value.
|
||||
pub const FUNCTION_BLOB_V2_TYPE: &str = "blob_v2";
|
||||
// Keep these byte limits aligned with Sophon's Function submission validation.
|
||||
pub(crate) const MAX_FUNCTION_SECRET_VALUE_BYTES: usize = 64 * 1024;
|
||||
const MAX_FUNCTION_SECRET_VALUES_BYTES: usize = 512 * 1024;
|
||||
|
||||
fn is_portable_environment_name(name: &str) -> bool {
|
||||
let mut bytes = name.bytes();
|
||||
matches!(bytes.next(), Some(b'A'..=b'Z' | b'a'..=b'z' | b'_'))
|
||||
&& bytes.all(|byte| matches!(byte, b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'_'))
|
||||
}
|
||||
|
||||
fn invalid_json(error: impl std::fmt::Display) -> Error {
|
||||
Error::InvalidInput {
|
||||
@@ -201,6 +208,11 @@ pub struct PythonEnvironmentSpec {
|
||||
}
|
||||
|
||||
/// Reproducible Python runtime definition understood by Sophon.
|
||||
///
|
||||
/// `env` contains non-secret values. Secret values are submission-only in the
|
||||
/// client model and do not become part of this public runtime identity;
|
||||
/// [`FunctionVersion::required_secrets`] contains names only. Sophon persists
|
||||
/// submitted values separately in the private execution artifact.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
#[non_exhaustive]
|
||||
pub enum PythonRuntimeSpec {
|
||||
@@ -210,33 +222,6 @@ pub enum PythonRuntimeSpec {
|
||||
environment: PythonEnvironmentSpec,
|
||||
env: BTreeMap<String, String>,
|
||||
},
|
||||
/// The GPU-enabled Sophon-managed Python runtime.
|
||||
///
|
||||
/// # Examples
|
||||
///
|
||||
/// ```
|
||||
/// use std::collections::BTreeMap;
|
||||
/// use lancedb::function::{PythonEnvironmentSpec, PythonRuntimeSpec};
|
||||
///
|
||||
/// let runtime = PythonRuntimeSpec::PythonV2 {
|
||||
/// python_version: "3.12".to_string(),
|
||||
/// environment: PythonEnvironmentSpec {
|
||||
/// kind: "pip".to_string(),
|
||||
/// packages: vec!["cupy-cuda12x".to_string()],
|
||||
/// channels: Vec::new(),
|
||||
/// path: None,
|
||||
/// modules: Vec::new(),
|
||||
/// image: None,
|
||||
/// },
|
||||
/// env: BTreeMap::new(),
|
||||
/// };
|
||||
/// assert!(runtime.requires_gpu());
|
||||
/// ```
|
||||
PythonV2 {
|
||||
python_version: String,
|
||||
environment: PythonEnvironmentSpec,
|
||||
env: BTreeMap<String, String>,
|
||||
},
|
||||
/// A runtime kind introduced by a newer server.
|
||||
///
|
||||
/// Unknown payload fields are intentionally not retained because the
|
||||
@@ -249,101 +234,61 @@ impl PythonRuntimeSpec {
|
||||
pub fn kind(&self) -> &str {
|
||||
match self {
|
||||
Self::Python { .. } => "python",
|
||||
Self::PythonV2 { .. } => "python_v2",
|
||||
Self::Unrecognized { kind } => kind,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Python version for a known Python runtime, or `None` for an unknown kind.
|
||||
/// The Python version for the V1 runtime, or `None` for an unknown kind.
|
||||
pub fn python_version(&self) -> Option<&str> {
|
||||
match self {
|
||||
Self::Python { python_version, .. } | Self::PythonV2 { python_version, .. } => {
|
||||
Some(python_version)
|
||||
}
|
||||
Self::Python { python_version, .. } => Some(python_version),
|
||||
Self::Unrecognized { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// The Python environment for a known Python runtime, or `None` for an unknown kind.
|
||||
/// The Python environment for the V1 runtime, or `None` for an unknown kind.
|
||||
pub fn environment(&self) -> Option<&PythonEnvironmentSpec> {
|
||||
match self {
|
||||
Self::Python { environment, .. } | Self::PythonV2 { environment, .. } => {
|
||||
Some(environment)
|
||||
}
|
||||
Self::Python { environment, .. } => Some(environment),
|
||||
Self::Unrecognized { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Environment variables, or `None` for an unknown kind.
|
||||
/// Non-secret environment variables, or `None` for an unknown kind.
|
||||
pub fn env(&self) -> Option<&BTreeMap<String, String>> {
|
||||
match self {
|
||||
Self::Python { env, .. } | Self::PythonV2 { env, .. } => Some(env),
|
||||
Self::Python { env, .. } => Some(env),
|
||||
Self::Unrecognized { .. } => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the runtime requires a GPU selected by the execution platform.
|
||||
pub fn requires_gpu(&self) -> bool {
|
||||
matches!(self, Self::PythonV2 { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PythonRuntimeV1Wire {
|
||||
python_version: String,
|
||||
environment: PythonEnvironmentSpec,
|
||||
struct PythonRuntimeWire {
|
||||
kind: String,
|
||||
#[serde(default)]
|
||||
python_version: Option<String>,
|
||||
#[serde(default)]
|
||||
environment: Option<PythonEnvironmentSpec>,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
#[serde(default)]
|
||||
gpu: Option<Value>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PythonRuntimeV2Wire {
|
||||
python_version: String,
|
||||
environment: PythonEnvironmentSpec,
|
||||
#[serde(default)]
|
||||
env: BTreeMap<String, String>,
|
||||
gpu: bool,
|
||||
}
|
||||
|
||||
impl<'de> Deserialize<'de> for PythonRuntimeSpec {
|
||||
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
|
||||
let value = Value::deserialize(deserializer)?;
|
||||
let kind = value
|
||||
.get("kind")
|
||||
.ok_or_else(|| de::Error::missing_field("kind"))?
|
||||
.as_str()
|
||||
.ok_or_else(|| de::Error::custom("runtime.kind must be a string"))?
|
||||
.to_string();
|
||||
match kind.as_str() {
|
||||
"python" => {
|
||||
let wire: PythonRuntimeV1Wire =
|
||||
serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
if wire.gpu.is_some() {
|
||||
return Err(de::Error::custom(
|
||||
"python runtime with gpu requires kind='python_v2'",
|
||||
));
|
||||
}
|
||||
Ok(Self::Python {
|
||||
python_version: wire.python_version,
|
||||
environment: wire.environment,
|
||||
env: wire.env,
|
||||
})
|
||||
}
|
||||
"python_v2" => {
|
||||
let wire: PythonRuntimeV2Wire =
|
||||
serde_json::from_value(value).map_err(de::Error::custom)?;
|
||||
if !wire.gpu {
|
||||
return Err(de::Error::custom("runtime.gpu must be true"));
|
||||
}
|
||||
Ok(Self::PythonV2 {
|
||||
python_version: wire.python_version,
|
||||
environment: wire.environment,
|
||||
env: wire.env,
|
||||
})
|
||||
}
|
||||
_ => Ok(Self::Unrecognized { kind }),
|
||||
let wire = PythonRuntimeWire::deserialize(deserializer)?;
|
||||
if wire.kind == "python" {
|
||||
Ok(Self::Python {
|
||||
python_version: wire
|
||||
.python_version
|
||||
.ok_or_else(|| de::Error::missing_field("python_version"))?,
|
||||
environment: wire
|
||||
.environment
|
||||
.ok_or_else(|| de::Error::missing_field("environment"))?,
|
||||
env: wire.env,
|
||||
})
|
||||
} else {
|
||||
Ok(Self::Unrecognized { kind: wire.kind })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -357,8 +302,6 @@ impl Serialize for PythonRuntimeSpec {
|
||||
environment: &'a PythonEnvironmentSpec,
|
||||
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
|
||||
env: &'a BTreeMap<String, String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
gpu: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
@@ -376,19 +319,6 @@ impl Serialize for PythonRuntimeSpec {
|
||||
python_version,
|
||||
environment,
|
||||
env,
|
||||
gpu: None,
|
||||
}
|
||||
.serialize(serializer),
|
||||
Self::PythonV2 {
|
||||
python_version,
|
||||
environment,
|
||||
env,
|
||||
} => PythonRuntimeRef {
|
||||
kind: "python_v2",
|
||||
python_version,
|
||||
environment,
|
||||
env,
|
||||
gpu: Some(true),
|
||||
}
|
||||
.serialize(serializer),
|
||||
Self::Unrecognized { kind } => UnrecognizedRuntimeRef { kind }.serialize(serializer),
|
||||
@@ -398,8 +328,8 @@ impl Serialize for PythonRuntimeSpec {
|
||||
|
||||
/// Immutable Function version returned by the Enterprise catalog.
|
||||
///
|
||||
/// The GPU execution requirement is part of this identity. CPU and memory sizing,
|
||||
/// priority, concurrency, and retry policy belong to the execution platform.
|
||||
/// Scheduling resources, priority, concurrency, and retry policy belong to
|
||||
/// the submitting Job and are not part of this identity.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FunctionVersion {
|
||||
name: String,
|
||||
@@ -409,6 +339,8 @@ pub struct FunctionVersion {
|
||||
runtime: PythonRuntimeSpec,
|
||||
runtime_digest: String,
|
||||
environment_digest: String,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
required_secrets: Vec<String>,
|
||||
created_at: String,
|
||||
}
|
||||
|
||||
@@ -441,6 +373,12 @@ impl FunctionVersion {
|
||||
&self.environment_digest
|
||||
}
|
||||
|
||||
/// Required secret names. Resolved values exist only in Sophon's private
|
||||
/// execution artifact and worker launch path.
|
||||
pub fn required_secrets(&self) -> &[String] {
|
||||
&self.required_secrets
|
||||
}
|
||||
|
||||
pub fn created_at(&self) -> &str {
|
||||
&self.created_at
|
||||
}
|
||||
@@ -482,12 +420,115 @@ pub struct FunctionArtifactRequest {
|
||||
}
|
||||
|
||||
/// Stable request envelope for remote immutable Function registration.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
///
|
||||
/// Secret values are submission-only in the client model. Sophon persists them
|
||||
/// in the database-scoped private execution artifact; returned
|
||||
/// [`FunctionVersion`] and Job metadata contain only
|
||||
/// [`Self::required_secrets`] names. Debug formatting always redacts values.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct FunctionRegistrationRequest {
|
||||
pub name: String,
|
||||
pub artifact: FunctionArtifactRequest,
|
||||
pub signature: FunctionSignature,
|
||||
pub runtime: PythonRuntimeSpec,
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub required_secrets: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
|
||||
pub secret_values: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
impl FunctionRegistrationRequest {
|
||||
pub(crate) fn validate_secret_values(&self) -> Result<()> {
|
||||
let mut required = BTreeSet::new();
|
||||
for name in &self.required_secrets {
|
||||
if !is_portable_environment_name(name) {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"Function secret name {name:?} must be a portable environment variable name"
|
||||
),
|
||||
});
|
||||
}
|
||||
if !required.insert(name) {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("Function required_secrets contains duplicate name {name:?}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let PythonRuntimeSpec::Python { env, .. } = &self.runtime
|
||||
&& let Some(name) = required.iter().find(|name| env.contains_key(**name))
|
||||
{
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"Function runtime env and secret names must be disjoint: {name:?}"
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
let provided = self.secret_values.keys().collect::<BTreeSet<_>>();
|
||||
if required != provided {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "Function secret_values keys must exactly match required_secrets"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let mut total_bytes = 0usize;
|
||||
for (name, value) in &self.secret_values {
|
||||
if value.is_empty() {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("Function secret {name:?} value must be non-empty"),
|
||||
});
|
||||
}
|
||||
if value.contains('\0') {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("Function secret {name:?} value must not contain NUL"),
|
||||
});
|
||||
}
|
||||
if value.len() > MAX_FUNCTION_SECRET_VALUE_BYTES {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"Function secret {name:?} value exceeds the \
|
||||
{MAX_FUNCTION_SECRET_VALUE_BYTES}-byte limit"
|
||||
),
|
||||
});
|
||||
}
|
||||
total_bytes =
|
||||
total_bytes
|
||||
.checked_add(value.len())
|
||||
.ok_or_else(|| Error::InvalidInput {
|
||||
message: "Function secret values exceed the request byte limit".to_string(),
|
||||
})?;
|
||||
}
|
||||
if total_bytes > MAX_FUNCTION_SECRET_VALUES_BYTES {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"Function secret values exceed the \
|
||||
{MAX_FUNCTION_SECRET_VALUES_BYTES}-byte request limit"
|
||||
),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for FunctionRegistrationRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let secret_values = self
|
||||
.secret_values
|
||||
.keys()
|
||||
.map(|name| (name, "[REDACTED]"))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
formatter
|
||||
.debug_struct("FunctionRegistrationRequest")
|
||||
.field("name", &self.name)
|
||||
.field("artifact", &self.artifact)
|
||||
.field("signature", &self.signature)
|
||||
.field("runtime", &self.runtime)
|
||||
.field("required_secrets", &self.required_secrets)
|
||||
.field("secret_values", &secret_values)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl_json!(FunctionRegistrationRequest);
|
||||
@@ -672,9 +713,188 @@ impl RefreshColumnResult {
|
||||
|
||||
impl_json!(RefreshColumnResult);
|
||||
|
||||
#[cfg(test)]
|
||||
mod secret_value_tests {
|
||||
use super::{
|
||||
FunctionRegistrationRequest, MAX_FUNCTION_SECRET_VALUE_BYTES,
|
||||
MAX_FUNCTION_SECRET_VALUES_BYTES, PythonRuntimeSpec,
|
||||
};
|
||||
use crate::Error;
|
||||
|
||||
fn request() -> FunctionRegistrationRequest {
|
||||
FunctionRegistrationRequest::from_json(include_str!(
|
||||
"../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
|
||||
))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validates_secret_name_and_value_invariants() {
|
||||
let missing = request();
|
||||
assert!(matches!(
|
||||
missing.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("exactly match")
|
||||
));
|
||||
|
||||
let mut empty = request();
|
||||
empty
|
||||
.secret_values
|
||||
.insert("API_TOKEN".to_string(), String::new());
|
||||
assert!(matches!(
|
||||
empty.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("non-empty")
|
||||
));
|
||||
|
||||
let mut nul = request();
|
||||
nul.secret_values
|
||||
.insert("API_TOKEN".to_string(), "before\0after".to_string());
|
||||
assert!(matches!(
|
||||
nul.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("NUL")
|
||||
));
|
||||
|
||||
let mut unexpected = request();
|
||||
unexpected
|
||||
.secret_values
|
||||
.insert("OTHER".to_string(), "value".to_string());
|
||||
assert!(matches!(
|
||||
unexpected.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("exactly match")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_duplicate_and_overlapping_secret_declarations() {
|
||||
let mut invalid_name = request();
|
||||
invalid_name.required_secrets = vec!["BAD=NAME".to_string()];
|
||||
invalid_name
|
||||
.secret_values
|
||||
.insert("BAD=NAME".to_string(), "secret".to_string());
|
||||
|
||||
let mut duplicate = request();
|
||||
duplicate.required_secrets = vec!["API_TOKEN".to_string(), "API_TOKEN".to_string()];
|
||||
duplicate
|
||||
.secret_values
|
||||
.insert("API_TOKEN".to_string(), "secret".to_string());
|
||||
|
||||
let mut overlap = request();
|
||||
overlap
|
||||
.secret_values
|
||||
.insert("API_TOKEN".to_string(), "secret".to_string());
|
||||
if let PythonRuntimeSpec::Python { env, .. } = &mut overlap.runtime {
|
||||
env.insert("API_TOKEN".to_string(), "public".to_string());
|
||||
}
|
||||
|
||||
assert!(matches!(
|
||||
invalid_name.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("portable environment variable")
|
||||
));
|
||||
assert!(matches!(
|
||||
duplicate.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("duplicate")
|
||||
));
|
||||
assert!(matches!(
|
||||
overlap.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("must be disjoint")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enforces_portable_secret_name_boundaries() {
|
||||
for name in ["A", "_", "A0_"] {
|
||||
let mut request = request();
|
||||
request.required_secrets = vec![name.to_string()];
|
||||
request
|
||||
.secret_values
|
||||
.insert(name.to_string(), "secret".to_string());
|
||||
request.validate_secret_values().unwrap();
|
||||
}
|
||||
|
||||
for name in ["", "0TOKEN", "BAD-NAME", "TÖKEN"] {
|
||||
let mut request = request();
|
||||
request.required_secrets = vec![name.to_string()];
|
||||
request
|
||||
.secret_values
|
||||
.insert(name.to_string(), "secret".to_string());
|
||||
assert!(matches!(
|
||||
request.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message })
|
||||
if message.contains("portable environment variable")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_exact_secret_value_utf8_byte_limit() {
|
||||
for value in [
|
||||
"x".repeat(MAX_FUNCTION_SECRET_VALUE_BYTES),
|
||||
"é".repeat(MAX_FUNCTION_SECRET_VALUE_BYTES / "é".len()),
|
||||
] {
|
||||
assert_eq!(value.len(), MAX_FUNCTION_SECRET_VALUE_BYTES);
|
||||
let mut request = request();
|
||||
request.secret_values.insert("API_TOKEN".to_string(), value);
|
||||
request.validate_secret_values().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_secret_value_over_utf8_byte_limit() {
|
||||
for value in [
|
||||
"x".repeat(MAX_FUNCTION_SECRET_VALUE_BYTES + 1),
|
||||
"é".repeat(MAX_FUNCTION_SECRET_VALUE_BYTES / "é".len() + 1),
|
||||
] {
|
||||
assert!(value.len() > MAX_FUNCTION_SECRET_VALUE_BYTES);
|
||||
let mut request = request();
|
||||
request.secret_values.insert("API_TOKEN".to_string(), value);
|
||||
assert!(matches!(
|
||||
request.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message }) if message.contains("65536-byte limit")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_aggregate_secret_value_bytes_over_server_limit() {
|
||||
let mut request = request();
|
||||
request.required_secrets = (0..9).map(|index| format!("SECRET_{index}")).collect();
|
||||
request.secret_values = request
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|name| (name.clone(), "x".repeat(MAX_FUNCTION_SECRET_VALUE_BYTES)))
|
||||
.collect();
|
||||
|
||||
assert!(matches!(
|
||||
request.validate_secret_values(),
|
||||
Err(Error::InvalidInput { message })
|
||||
if message.contains(&format!("{MAX_FUNCTION_SECRET_VALUES_BYTES}-byte request limit"))
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_exact_aggregate_secret_value_byte_limit() {
|
||||
let mut request = request();
|
||||
request.required_secrets = (0..8).map(|index| format!("SECRET_{index}")).collect();
|
||||
request.secret_values = request
|
||||
.required_secrets
|
||||
.iter()
|
||||
.map(|name| (name.clone(), "x".repeat(MAX_FUNCTION_SECRET_VALUE_BYTES)))
|
||||
.collect();
|
||||
|
||||
assert_eq!(
|
||||
request
|
||||
.secret_values
|
||||
.values()
|
||||
.map(String::len)
|
||||
.sum::<usize>(),
|
||||
MAX_FUNCTION_SECRET_VALUES_BYTES
|
||||
);
|
||||
request.validate_secret_values().unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod conda_environment_tests {
|
||||
use super::{PythonEnvironmentSpec, PythonRuntimeSpec};
|
||||
use super::PythonEnvironmentSpec;
|
||||
|
||||
#[test]
|
||||
fn conda_channels_round_trip_and_pip_stays_bare() {
|
||||
@@ -693,45 +913,4 @@ mod conda_environment_tests {
|
||||
serde_json::from_str(r#"{"kind":"pip","packages":["numpy"]}"#).unwrap();
|
||||
assert!(!serde_json::to_string(&pip).unwrap().contains("channels"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gpu_python_runtime_marker_round_trips_and_validates() {
|
||||
let runtime: PythonRuntimeSpec = serde_json::from_str(
|
||||
r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":true}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(runtime.kind(), "python_v2");
|
||||
assert!(runtime.requires_gpu());
|
||||
assert_eq!(
|
||||
super::canonical_json(&runtime).unwrap(),
|
||||
r#"{"environment":{"kind":"pip"},"gpu":true,"kind":"python_v2","python_version":"3.12"}"#
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
r#"{"kind":"python","python_version":"3.12","environment":{"kind":"pip"},"gpu":true}"#,
|
||||
r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"}}"#,
|
||||
r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":1}"#,
|
||||
r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":false}"#,
|
||||
r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":"true"}"#,
|
||||
r#"{"kind":"python_v2","python_version":"3.12","environment":{"kind":"pip"},"gpu":"H100"}"#,
|
||||
] {
|
||||
assert!(serde_json::from_str::<PythonRuntimeSpec>(invalid).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_runtime_discards_payload_before_known_field_validation() {
|
||||
for encoded in [
|
||||
r#"{"kind":"python_v3","gpu":{"model":"H100"}}"#,
|
||||
r#"{"kind":"python_v3","resources":[]}"#,
|
||||
r#"{"kind":"python_v3","python_version":3.15,"environment":{"kind":[]}}"#,
|
||||
] {
|
||||
let runtime: PythonRuntimeSpec = serde_json::from_str(encoded).unwrap();
|
||||
assert_eq!(runtime.kind(), "python_v3");
|
||||
assert_eq!(
|
||||
super::canonical_json(&runtime).unwrap(),
|
||||
r#"{"kind":"python_v3"}"#
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use lance::io::WrappingObjectStore;
|
||||
use object_store::{
|
||||
CopyOptions, Error, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta,
|
||||
ObjectStore, ObjectStoreExt, PutMultipartOptions, PutOptions, PutPayload, PutResult, Result,
|
||||
UploadPart, path::Path,
|
||||
UploadPart, list::PaginatedListStore, path::Path,
|
||||
};
|
||||
|
||||
use async_trait::async_trait;
|
||||
@@ -187,6 +187,14 @@ impl WrappingObjectStore for MirroringObjectStoreWrapper {
|
||||
secondary: self.secondary.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_paginated(
|
||||
&self,
|
||||
_store_prefix: &str,
|
||||
original: Arc<dyn PaginatedListStore>,
|
||||
) -> Option<Arc<dyn PaginatedListStore>> {
|
||||
Some(original)
|
||||
}
|
||||
}
|
||||
|
||||
// windows pathing can't be simply concatenated
|
||||
|
||||
@@ -12,7 +12,7 @@ use lance::io::WrappingObjectStore;
|
||||
use object_store::{
|
||||
CopyOptions, GetOptions, GetResult, ListResult, MultipartUpload, ObjectMeta, ObjectStore,
|
||||
PutMultipartOptions, PutOptions, PutPayload, PutResult, RenameOptions, Result as OSResult,
|
||||
UploadPart, path::Path,
|
||||
UploadPart, list::PaginatedListStore, path::Path,
|
||||
};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
@@ -57,6 +57,14 @@ impl WrappingObjectStore for IoStatsHolder {
|
||||
stats: self.0.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_paginated(
|
||||
&self,
|
||||
_store_prefix: &str,
|
||||
original: Arc<dyn PaginatedListStore>,
|
||||
) -> Option<Arc<dyn PaginatedListStore>> {
|
||||
Some(original)
|
||||
}
|
||||
}
|
||||
|
||||
impl IoTrackingStore {
|
||||
|
||||
@@ -7,6 +7,7 @@ use reqwest::{
|
||||
Body, Request, RequestBuilder, Response,
|
||||
header::{HeaderMap, HeaderValue},
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::{collections::HashMap, future::Future, str::FromStr, sync::Arc, time::Duration};
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
@@ -14,6 +15,60 @@ use crate::remote::db::RemoteOptions;
|
||||
use crate::remote::retry::{ResolvedRetryConfig, RetryCounter};
|
||||
|
||||
const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id");
|
||||
const REDACTED_JSON_VALUE: &str = "[REDACTED]";
|
||||
const SUPPRESSED_JSON_BODY: &str = "[JSON BODY SUPPRESSED]";
|
||||
|
||||
fn is_sensitive_json_field(name: &str) -> bool {
|
||||
name.to_ascii_lowercase().contains("secret")
|
||||
}
|
||||
|
||||
fn redact_sensitive_json_fields(value: &mut Value) {
|
||||
match value {
|
||||
Value::Object(fields) => {
|
||||
for (name, child) in fields {
|
||||
if is_sensitive_json_field(name) {
|
||||
*child = Value::String(REDACTED_JSON_VALUE.to_string());
|
||||
} else {
|
||||
redact_sensitive_json_fields(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
Value::Array(values) => values.iter_mut().for_each(redact_sensitive_json_fields),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn redacted_json_body(request: &Request) -> Option<String> {
|
||||
let body = request.body()?.as_bytes()?;
|
||||
let mut value = serde_json::from_slice(body).ok()?;
|
||||
redact_sensitive_json_fields(&mut value);
|
||||
serde_json::to_string(&value).ok()
|
||||
}
|
||||
|
||||
fn request_log_message(request: &Request, request_id: &str) -> String {
|
||||
let prefix = format!(
|
||||
"Sending request_id={}: {} {}",
|
||||
request_id,
|
||||
request.method(),
|
||||
request.url()
|
||||
);
|
||||
let content_type = request
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.split(';').next());
|
||||
if content_type.is_some_and(|value| value.eq_ignore_ascii_case("application/json")) {
|
||||
// Never format the raw Request here: its Debug representation is not a
|
||||
// redaction boundary and may include the original body. If the JSON body
|
||||
// cannot be structurally parsed, suppress it instead of logging raw bytes.
|
||||
let body = redacted_json_body(request).unwrap_or_else(|| SUPPRESSED_JSON_BODY.to_string());
|
||||
format!("{prefix} with body {body}")
|
||||
} else {
|
||||
// Method and URL are sufficient request context. Raw Request formatting
|
||||
// may expose headers or a non-JSON body, so it is never a logging fallback.
|
||||
prefix
|
||||
}
|
||||
}
|
||||
|
||||
/// Configuration for TLS/mTLS settings.
|
||||
#[derive(Clone, Debug)]
|
||||
@@ -839,22 +894,9 @@ impl<S: HttpSend> RestfulLanceDbClient<S> {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn log_request(&self, request: &Request, request_id: &String) {
|
||||
pub(crate) fn log_request(&self, request: &Request, request_id: &str) {
|
||||
if log::log_enabled!(log::Level::Debug) {
|
||||
let content_type = request
|
||||
.headers()
|
||||
.get("content-type")
|
||||
.map(|v| v.to_str().unwrap());
|
||||
if content_type == Some("application/json") {
|
||||
let body = request.body().as_ref().unwrap().as_bytes().unwrap();
|
||||
let body = String::from_utf8_lossy(body);
|
||||
debug!(
|
||||
"Sending request_id={}: {:?} with body {}",
|
||||
request_id, request, body
|
||||
);
|
||||
} else {
|
||||
debug!("Sending request_id={}: {:?}", request_id, request);
|
||||
}
|
||||
debug!("{}", request_log_message(request, request_id));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1077,6 +1119,49 @@ mod tests {
|
||||
ENV_MUTEX.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_request_log_message_redacts_secrets_and_never_formats_raw_requests() {
|
||||
const SECRET_SENTINEL: &str = "udf-secret-log-sentinel-7e4e";
|
||||
const MALFORMED_SENTINEL: &str = "malformed-secret-log-sentinel-b652";
|
||||
const NON_JSON_SENTINEL: &str = "non-json-secret-log-sentinel-7fd1";
|
||||
|
||||
let request = reqwest::Client::new()
|
||||
.post("https://example.com/v1/functions/create")
|
||||
.json(&serde_json::json!({
|
||||
"name": "uses_secret",
|
||||
"nested": {
|
||||
"secret_values": {"OPENAI_API_KEY": SECRET_SENTINEL},
|
||||
"safe": "visible-value"
|
||||
}
|
||||
}))
|
||||
.build()
|
||||
.unwrap();
|
||||
let log_message = request_log_message(&request, "valid-json");
|
||||
|
||||
let malformed_request = reqwest::Client::new()
|
||||
.post("https://example.com/v1/functions/create")
|
||||
.header("content-type", "application/json; charset=utf-8")
|
||||
.body(format!(r#"{{"secret_values":"{MALFORMED_SENTINEL}""#))
|
||||
.build()
|
||||
.unwrap();
|
||||
let malformed_log_message = request_log_message(&malformed_request, "malformed-json");
|
||||
|
||||
let non_json_request = reqwest::Client::new()
|
||||
.post("https://example.com/v1/functions/create")
|
||||
.header("content-type", "text/plain")
|
||||
.body(NON_JSON_SENTINEL)
|
||||
.build()
|
||||
.unwrap();
|
||||
let non_json_log_message = request_log_message(&non_json_request, "non-json");
|
||||
|
||||
assert!(log_message.contains("visible-value"));
|
||||
assert!(log_message.contains(REDACTED_JSON_VALUE));
|
||||
assert!(!log_message.contains(SECRET_SENTINEL));
|
||||
assert!(malformed_log_message.contains(SUPPRESSED_JSON_BODY));
|
||||
assert!(!malformed_log_message.contains(MALFORMED_SENTINEL));
|
||||
assert!(!non_json_log_message.contains(NON_JSON_SENTINEL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timeout_config_default() {
|
||||
let config = TimeoutConfig::default();
|
||||
|
||||
@@ -554,6 +554,7 @@ impl<S: HttpSend> Database for RemoteDatabase<S> {
|
||||
&self,
|
||||
request: FunctionRegistrationRequest,
|
||||
) -> Result<Job<FunctionVersion>> {
|
||||
request.validate_secret_values()?;
|
||||
let req = self.client.post("/v1/functions/create").json(&request);
|
||||
let (request_id, response) = self.client.send(req).await?;
|
||||
let response = self.client.check_response(&request_id, response).await?;
|
||||
@@ -2642,7 +2643,8 @@ mod tests {
|
||||
);
|
||||
const FUNCTION_JOB: &str =
|
||||
include_str!("../../tests/fixtures/first_class_functions/v1/remote_function_job.json");
|
||||
let expected: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
|
||||
let mut expected: serde_json::Value = serde_json::from_str(REQUEST).unwrap();
|
||||
expected["secret_values"] = serde_json::json!({"API_TOKEN": "secret-value"});
|
||||
let conn = Connection::new_with_handler(move |request| match request.url().path() {
|
||||
"/v1/functions/create" => {
|
||||
assert_eq!(request.method(), &reqwest::Method::POST);
|
||||
@@ -2660,7 +2662,10 @@ mod tests {
|
||||
.unwrap(),
|
||||
path => panic!("unexpected path: {path}"),
|
||||
});
|
||||
let request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
|
||||
let mut request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
|
||||
request
|
||||
.secret_values
|
||||
.insert("API_TOKEN".to_string(), "secret-value".to_string());
|
||||
let job = conn.create_function_async(request).await.unwrap();
|
||||
assert_eq!(job.id(), Some("job-function-1"));
|
||||
let version = job.wait().await.unwrap();
|
||||
@@ -2668,6 +2673,32 @@ mod tests {
|
||||
assert_eq!(version.version(), "fv_01K3EXACT");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_function_async_validates_secrets_before_serialization_and_send() {
|
||||
const REQUEST: &str = include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_registration_request.json"
|
||||
);
|
||||
let sends = Arc::new(AtomicUsize::new(0));
|
||||
let sends_ref = sends.clone();
|
||||
let conn = Connection::new_with_handler(move |_| {
|
||||
sends_ref.fetch_add(1, Ordering::SeqCst);
|
||||
http::Response::builder().status(500).body("").unwrap()
|
||||
});
|
||||
let mut request = crate::function::FunctionRegistrationRequest::from_json(REQUEST).unwrap();
|
||||
request.secret_values.insert(
|
||||
"API_TOKEN".to_string(),
|
||||
"x".repeat(crate::function::MAX_FUNCTION_SECRET_VALUE_BYTES + 1),
|
||||
);
|
||||
|
||||
let error = conn.create_function_async(request).await.unwrap_err();
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
Error::InvalidInput { message } if message.contains("65536-byte limit")
|
||||
));
|
||||
assert_eq!(sends.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_get_function_requires_and_sends_exact_version() {
|
||||
const VERSION: &str = include_str!(
|
||||
|
||||
@@ -3180,8 +3180,8 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
|
||||
self.schema().await?.as_ref(),
|
||||
"schema evolution",
|
||||
)?;
|
||||
// The server plans the declaration against its table schema, including
|
||||
// Blob v2 semantics inherited by a direct field projection.
|
||||
// The server plans the declaration: expression validation, type
|
||||
// inference and the persisted binding all happen there.
|
||||
let entries = columns
|
||||
.iter()
|
||||
.map(
|
||||
@@ -5734,8 +5734,9 @@ mod tests {
|
||||
))
|
||||
.execute()
|
||||
.await;
|
||||
let Err(err) = result else {
|
||||
panic!("legacy remote query unexpectedly succeeded")
|
||||
let err = match result {
|
||||
Ok(_) => panic!("legacy remote query unexpectedly succeeded"),
|
||||
Err(err) => err,
|
||||
};
|
||||
|
||||
assert!(
|
||||
@@ -7387,8 +7388,8 @@ mod tests {
|
||||
assert_eq!(result.version, if old_server { 0 } else { 43 });
|
||||
}
|
||||
|
||||
/// A declaration is sent as `{name, computed}` for the server to plan; the
|
||||
/// client never types the expression itself.
|
||||
/// A declaration is sent as `{name, computed}` entries for the server to
|
||||
/// plan; the client never types the expression itself.
|
||||
#[tokio::test]
|
||||
async fn test_add_computed_columns_sends_the_expression() {
|
||||
let table = Table::new_with_handler("my_table", |request| match request.url().path() {
|
||||
@@ -7464,93 +7465,6 @@ mod tests {
|
||||
assert_eq!(result.version, 8);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_function_column_allows_an_existing_binding() {
|
||||
let binding = crate::function::FunctionBinding::from_json(include_str!(
|
||||
"../../tests/fixtures/first_class_functions/v1/remote_function_binding.json"
|
||||
))
|
||||
.unwrap();
|
||||
let binding_metadata = crate::table::computed_columns::function_bindings_metadata(
|
||||
std::slice::from_ref(&binding),
|
||||
)
|
||||
.unwrap();
|
||||
let mut fields = vec![
|
||||
Field::new("title", DataType::Utf8, true),
|
||||
Field::new("body", DataType::Utf8, true),
|
||||
];
|
||||
fields.extend(binding.outputs().iter().map(|output| {
|
||||
let data_type = match output.arrow_type.as_str() {
|
||||
"utf8" => DataType::Utf8,
|
||||
"int64" => DataType::Int64,
|
||||
other => panic!("unexpected fixture output type {other}"),
|
||||
};
|
||||
Field::new(&output.output_name, data_type, true).with_metadata(
|
||||
crate::table::computed_columns::function_computed_column_metadata(
|
||||
binding.binding_id(),
|
||||
output.output_ordinal,
|
||||
&["title".into(), "body".into()],
|
||||
),
|
||||
)
|
||||
}));
|
||||
let schema = Schema::new_with_metadata(
|
||||
fields,
|
||||
HashMap::from([(
|
||||
crate::table::computed_columns::FUNCTION_BINDINGS_META_KEY.to_string(),
|
||||
binding_metadata,
|
||||
)]),
|
||||
);
|
||||
let table =
|
||||
Table::new_with_handler("my_table", move |request| match request.url().path() {
|
||||
"/v1/table/my_table/describe/" => http::Response::builder()
|
||||
.status(200)
|
||||
.body(describe_response(&schema))
|
||||
.unwrap(),
|
||||
"/v1/table/my_table/add_columns/" => {
|
||||
let actual: serde_json::Value =
|
||||
serde_json::from_slice(request.body().unwrap().as_bytes().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
actual["new_columns"],
|
||||
serde_json::json!([
|
||||
{"name":"secondary_text","all_null":true},
|
||||
{"name":"secondary_token_count","all_null":true}
|
||||
])
|
||||
);
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
.body(r#"{"version":10}"#.to_string())
|
||||
.unwrap()
|
||||
}
|
||||
path => panic!("Unexpected path: {path}"),
|
||||
});
|
||||
let application = crate::function::FunctionApplication::from_json(
|
||||
r#"{
|
||||
"function":{"name":"text_features","version":"fv_01K3TEXT"},
|
||||
"inputs":[
|
||||
{"parameter":"title","kind":"column","value":{"path":"title"}},
|
||||
{"parameter":"body","kind":"column","value":{"path":"body"}}
|
||||
],
|
||||
"output":{"kind":"named_struct","fields":[
|
||||
{"name":"normalized_text","arrow_type":"utf8","nullable":false},
|
||||
{"name":"token_count","arrow_type":"int64","nullable":false}
|
||||
]},
|
||||
"columns":{
|
||||
"normalized_text":"secondary_text",
|
||||
"token_count":"secondary_token_count"
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let result = table
|
||||
.add_columns()
|
||||
.function(application)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(result.version, 10);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_add_fixed_size_list_function_column_declares_the_vector_type() {
|
||||
let table = Table::new_with_handler("my_table", |request| {
|
||||
|
||||
@@ -750,8 +750,8 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync {
|
||||
/// Declare computed columns, each defined by a SQL expression.
|
||||
///
|
||||
/// Where the declaration is planned depends on the backend: a local table
|
||||
/// validates and types the expression itself, while a remote one sends the
|
||||
/// expression for the server to plan.
|
||||
/// validates and types the expression itself, a remote one sends the text
|
||||
/// for the server to plan.
|
||||
async fn add_computed_columns(
|
||||
&self,
|
||||
_columns: &[(String, String)],
|
||||
@@ -4183,6 +4183,14 @@ mod tests {
|
||||
parent_list_calls: self.parent_list_calls.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn wrap_paginated(
|
||||
&self,
|
||||
_store_prefix: &str,
|
||||
_original: Arc<dyn object_store::list::PaginatedListStore>,
|
||||
) -> Option<Arc<dyn object_store::list::PaginatedListStore>> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -4286,6 +4294,14 @@ mod tests {
|
||||
self.called.store(true, Ordering::Relaxed);
|
||||
original
|
||||
}
|
||||
|
||||
fn wrap_paginated(
|
||||
&self,
|
||||
_store_prefix: &str,
|
||||
original: Arc<dyn object_store::list::PaginatedListStore>,
|
||||
) -> Option<Arc<dyn object_store::list::PaginatedListStore>> {
|
||||
Some(original)
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -5747,13 +5763,6 @@ mod tests {
|
||||
assert!(index_bytes > 0);
|
||||
assert_eq!(with_index, data_only + index_bytes);
|
||||
|
||||
// Release builds reject unstable overlay datasets unless explicitly opted in.
|
||||
if !lance_table::feature_flags::can_read_dataset(
|
||||
lance_table::feature_flags::FLAG_UNSTABLE_DATA_OVERLAY_FILES,
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Commit an overlay file supplying new `foo` values for the first three
|
||||
// rows of fragment 0. There is no high-level API that writes overlays
|
||||
// yet, so write the overlay's data file and commit the `DataOverlay`
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -300,7 +300,7 @@ async fn build_read_context(
|
||||
for shard_id in shard_ids {
|
||||
let manifest_store =
|
||||
ShardManifestStore::new(store.clone(), &base_path, shard_id, scan_batch_size);
|
||||
if let Some(manifest) = manifest_store.read_latest().await? {
|
||||
if let Some(manifest) = manifest_store.latest().await? {
|
||||
snapshots.push(snapshot_from_manifest(shard_id, &manifest, &exclude));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,6 @@
|
||||
//! therefore idempotent and does not observe input mutation -- once a row is
|
||||
//! filled, changing what the expression reads leaves the stored result alone.
|
||||
//!
|
||||
//! A column's computed inputs are filled first -- the dependency graph is
|
||||
//! walked once, each reachable column filled once in dependency order, each
|
||||
//! fill its own commit. Every fill in the pass, the requested column's
|
||||
//! included, covers only the fragments of the snapshot the pass started
|
||||
//! from: a commit may rebase over a concurrent append, and the fragment that
|
||||
//! admits carries placeholder nulls no earlier fill covered, so it waits for
|
||||
//! a later refresh rather than being read as values. Two concurrent fills of
|
||||
//! one input collide on its field in lance's conflict check, so a dependent
|
||||
//! fill can only commit over inputs that were durable when it read them.
|
||||
//!
|
||||
//! Two passes per fragment. The first scans only the unfilled live rows and
|
||||
//! evaluates the expression over them, which yields the exact fill count and
|
||||
//! decides whether the fragment is staged at all -- a fragment where nothing
|
||||
@@ -29,14 +19,10 @@
|
||||
//! inputs masked to null first, so a poison value in a row nobody is filling
|
||||
//! cannot fail the refresh.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_array::{
|
||||
Array, ArrayRef, BooleanArray, LargeBinaryArray, RecordBatch, RecordBatchOptions, StructArray,
|
||||
new_null_array,
|
||||
};
|
||||
use arrow_schema::{DataType, Field as ArrowField, Schema as ArrowSchema};
|
||||
use arrow_array::{ArrayRef, BooleanArray, RecordBatch, RecordBatchOptions};
|
||||
use arrow_schema::Schema as ArrowSchema;
|
||||
use datafusion_expr::ColumnarValue;
|
||||
use futures::{Stream, StreamExt, TryStreamExt};
|
||||
use lance::Dataset;
|
||||
@@ -44,7 +30,7 @@ use lance::dataset::WriteDestination;
|
||||
use lance::dataset::fragment::FileFragment;
|
||||
use lance::dataset::transaction::Operation;
|
||||
use lance_core::ROW_ID;
|
||||
use lance_core::datatypes::{BlobHandling, Schema as LanceSchema};
|
||||
use lance_core::datatypes::Schema as LanceSchema;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::computed_columns::{BoundExpression, ComputedColumnKind, computed_column_from_field};
|
||||
@@ -55,8 +41,7 @@ use crate::{Error, Result};
|
||||
/// The result of refreshing a computed column.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)]
|
||||
pub struct RefreshColumnResult {
|
||||
/// Rows that had a value computed, in the requested column only; inputs
|
||||
/// filled on its behalf are not counted.
|
||||
/// Rows that had a value computed.
|
||||
#[serde(default)]
|
||||
pub rows_filled: u64,
|
||||
/// The commit version associated with the operation.
|
||||
@@ -67,7 +52,6 @@ pub struct RefreshColumnResult {
|
||||
struct RefreshExecution {
|
||||
result: RefreshColumnResult,
|
||||
source_version: u64,
|
||||
published_version: Option<u64>,
|
||||
}
|
||||
|
||||
/// Internal implementation of the refresh logic.
|
||||
@@ -90,12 +74,7 @@ async fn execute_refresh_column_with_source(
|
||||
|
||||
let expression = declared_expression(&dataset, column)?;
|
||||
let schema = Arc::new(ArrowSchema::from(dataset.schema()));
|
||||
let bound = Arc::new(super::computed_columns::bind(
|
||||
schema.clone(),
|
||||
column,
|
||||
&expression,
|
||||
)?);
|
||||
ensure_inputs_filled(&dataset, &schema, column, &bound).await?;
|
||||
let bound = Arc::new(super::computed_columns::bind(schema, column, &expression)?);
|
||||
let field = dataset
|
||||
.schema()
|
||||
.field(column)
|
||||
@@ -108,7 +87,6 @@ async fn execute_refresh_column_with_source(
|
||||
fields: vec![field.clone()],
|
||||
metadata: Default::default(),
|
||||
};
|
||||
let output_is_blob = field.is_blob_v2();
|
||||
|
||||
let mut rows_filled = 0u64;
|
||||
let mut replacements = Vec::new();
|
||||
@@ -118,30 +96,29 @@ async fn execute_refresh_column_with_source(
|
||||
continue;
|
||||
}
|
||||
rows_filled += gained;
|
||||
let values =
|
||||
fill_stream(&dataset, &fragment, bound.clone(), column, output_is_blob).await?;
|
||||
let values = fill_stream(&dataset, &fragment, bound.clone(), column).await?;
|
||||
replacements.push(fragment.write_columns(values, &column_schema).await?);
|
||||
}
|
||||
|
||||
let source_version = dataset.version().version;
|
||||
if replacements.is_empty() {
|
||||
let source_version = dataset.version().version;
|
||||
return Ok(RefreshExecution {
|
||||
result: RefreshColumnResult {
|
||||
rows_filled: 0,
|
||||
version: source_version,
|
||||
},
|
||||
source_version,
|
||||
published_version: None,
|
||||
});
|
||||
}
|
||||
|
||||
let read_version = dataset.version().version;
|
||||
// The dataset's own session, so registrations and caches survive the
|
||||
// commit being installed on the handle.
|
||||
let session = dataset.session();
|
||||
let new_dataset = Dataset::commit(
|
||||
WriteDestination::Dataset(dataset.clone()),
|
||||
Operation::DataReplacement { replacements },
|
||||
Some(source_version),
|
||||
Some(read_version),
|
||||
None,
|
||||
None,
|
||||
session,
|
||||
@@ -156,52 +133,10 @@ async fn execute_refresh_column_with_source(
|
||||
rows_filled,
|
||||
version,
|
||||
},
|
||||
source_version,
|
||||
published_version: Some(version),
|
||||
source_version: read_version,
|
||||
})
|
||||
}
|
||||
|
||||
/// Refuse while a computed input still has rows a refresh of it would fill:
|
||||
/// read now, its placeholder null would be evaluated as a value and kept.
|
||||
async fn ensure_inputs_filled(
|
||||
dataset: &Dataset,
|
||||
schema: &Arc<ArrowSchema>,
|
||||
column: &str,
|
||||
bound: &BoundExpression,
|
||||
) -> Result<()> {
|
||||
for input in &bound.roots {
|
||||
let Some(declaration) = schema
|
||||
.field_with_name(input)
|
||||
.ok()
|
||||
.and_then(computed_column_from_field)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
let ComputedColumnKind::Sql { expression } = &declaration.kind else {
|
||||
return Err(Error::NotSupported {
|
||||
message: format!(
|
||||
"computed column '{column}' reads '{input}', whose fill state this \
|
||||
refresh cannot check; refresh '{input}' first"
|
||||
),
|
||||
});
|
||||
};
|
||||
let input_bound = super::computed_columns::bind(schema.clone(), input, expression)?;
|
||||
let mut unfilled = 0u64;
|
||||
for fragment in dataset.get_fragments() {
|
||||
unfilled += count_fragment_gains(dataset, &fragment, &input_bound, input).await?;
|
||||
}
|
||||
if unfilled > 0 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!(
|
||||
"computed column '{column}' reads '{input}', which has {unfilled} unfilled \
|
||||
rows; refresh '{input}' first"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the refresh as a [`Job`] in this process.
|
||||
pub(crate) async fn execute_refresh_column_async(
|
||||
table: &NativeTable,
|
||||
@@ -225,7 +160,8 @@ pub(crate) async fn execute_refresh_column_async(
|
||||
rows_failed: 0,
|
||||
rows_remaining: 0,
|
||||
source_version: execution.source_version,
|
||||
published_version: execution.published_version,
|
||||
published_version: (execution.result.rows_filled > 0)
|
||||
.then_some(execution.result.version),
|
||||
})
|
||||
})))
|
||||
}
|
||||
@@ -300,15 +236,12 @@ fn evaluation_batch(
|
||||
mask_out: Option<&BooleanArray>,
|
||||
) -> lance_core::Result<RecordBatch> {
|
||||
let mut columns = Vec::with_capacity(bound.roots.len());
|
||||
let mut fields = Vec::with_capacity(bound.roots.len());
|
||||
for name in &bound.roots {
|
||||
let index = batch.schema_ref().index_of(name).map_err(|_| {
|
||||
let column = batch.column_by_name(name).ok_or_else(|| {
|
||||
lance_core::Error::invalid_input(format!(
|
||||
"refreshing a computed column read no {name} column"
|
||||
))
|
||||
})?;
|
||||
let column = batch.column(index);
|
||||
fields.push(batch.schema_ref().field(index).clone());
|
||||
// Rows outside the mask must not reach the expression: a value in a
|
||||
// deleted or already-filled row can be one it would choke on.
|
||||
columns.push(match mask_out {
|
||||
@@ -317,7 +250,7 @@ fn evaluation_batch(
|
||||
});
|
||||
}
|
||||
Ok(RecordBatch::try_new_with_options(
|
||||
Arc::new(ArrowSchema::new(fields)),
|
||||
bound.read_schema.clone(),
|
||||
columns,
|
||||
&RecordBatchOptions::new().with_row_count(Some(batch.num_rows())),
|
||||
)?)
|
||||
@@ -338,99 +271,6 @@ fn evaluate(bound: &BoundExpression, batch: &RecordBatch) -> lance_core::Result<
|
||||
}
|
||||
}
|
||||
|
||||
fn materialized_blob_ids(schema: &LanceSchema, paths: &[String]) -> Result<HashSet<u32>> {
|
||||
paths
|
||||
.iter()
|
||||
.map(|path| {
|
||||
let field = schema
|
||||
.resolve(path)
|
||||
.and_then(|fields| fields.last().copied())
|
||||
.ok_or_else(|| Error::InvalidInput {
|
||||
message: format!("computed Blob input '{path}' no longer exists"),
|
||||
})?;
|
||||
if !field.is_blob_v2() {
|
||||
return Err(Error::InvalidInput {
|
||||
message: format!("computed Blob input '{path}' is no longer Blob v2"),
|
||||
});
|
||||
}
|
||||
u32::try_from(field.id).map_err(|_| Error::InvalidInput {
|
||||
message: format!(
|
||||
"computed Blob input '{path}' has invalid field id {}",
|
||||
field.id
|
||||
),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn configure_blob_inputs(
|
||||
scanner: &mut lance::dataset::scanner::Scanner,
|
||||
schema: &LanceSchema,
|
||||
bound: &BoundExpression,
|
||||
extra_blob_id: Option<u32>,
|
||||
) -> Result<()> {
|
||||
let mut ids = materialized_blob_ids(schema, &bound.blob_paths)?;
|
||||
ids.extend(extra_blob_id);
|
||||
scanner.blob_handling(BlobHandling::SomeBlobsBinary(ids));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn blob_array_from_binary(
|
||||
array: &ArrayRef,
|
||||
target_field: &ArrowField,
|
||||
) -> lance_core::Result<ArrayRef> {
|
||||
let values = array
|
||||
.as_any()
|
||||
.downcast_ref::<LargeBinaryArray>()
|
||||
.ok_or_else(|| {
|
||||
lance_core::Error::invalid_input(format!(
|
||||
"a Blob v2 computed output produced {}, expected LargeBinary",
|
||||
array.data_type()
|
||||
))
|
||||
})?;
|
||||
let mut builder = lance::blob::BlobArrayBuilder::new(values.len());
|
||||
for index in 0..values.len() {
|
||||
if values.is_null(index) {
|
||||
builder.push_null()?;
|
||||
} else {
|
||||
builder.push_bytes(values.value(index))?;
|
||||
}
|
||||
}
|
||||
let minimal = builder.finish()?;
|
||||
let minimal = minimal
|
||||
.as_any()
|
||||
.downcast_ref::<StructArray>()
|
||||
.ok_or_else(|| lance_core::Error::internal("Blob builder returned a non-struct array"))?;
|
||||
let DataType::Struct(target_fields) = target_field.data_type() else {
|
||||
return Err(lance_core::Error::invalid_input(format!(
|
||||
"Blob v2 output field '{}' has non-struct type {}",
|
||||
target_field.name(),
|
||||
target_field.data_type()
|
||||
)));
|
||||
};
|
||||
let columns = target_fields
|
||||
.iter()
|
||||
.map(|field| match field.name().as_str() {
|
||||
"data" | "uri" => minimal
|
||||
.column_by_name(field.name())
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
lance_core::Error::internal(format!("Blob builder omitted '{}'", field.name()))
|
||||
}),
|
||||
"position" | "size" => Ok(new_null_array(field.data_type(), minimal.len())),
|
||||
name => Err(lance_core::Error::invalid_input(format!(
|
||||
"Blob v2 output field '{}' has unsupported logical child '{name}'",
|
||||
target_field.name()
|
||||
))),
|
||||
})
|
||||
.collect::<lance_core::Result<Vec<_>>>()?;
|
||||
Ok(Arc::new(StructArray::try_new(
|
||||
target_fields.clone(),
|
||||
columns,
|
||||
minimal.nulls().cloned(),
|
||||
)?))
|
||||
}
|
||||
|
||||
/// How many rows of one fragment would gain a value.
|
||||
///
|
||||
/// Scans only the unfilled live rows -- deleted rows never reach the
|
||||
@@ -449,7 +289,6 @@ async fn count_fragment_gains(
|
||||
.with_row_id()
|
||||
.filter(&format!("{} IS NULL", quote_identifier(column)))?
|
||||
.project(&bound.roots)?;
|
||||
configure_blob_inputs(&mut scanner, dataset.schema(), bound, None)?;
|
||||
|
||||
let mut gained = 0u64;
|
||||
let mut batches = scanner.try_into_stream().await?;
|
||||
@@ -471,7 +310,6 @@ async fn fill_stream(
|
||||
fragment: &FileFragment,
|
||||
bound: Arc<BoundExpression>,
|
||||
column: &str,
|
||||
output_is_blob: bool,
|
||||
) -> Result<impl Stream<Item = lance_core::Result<RecordBatch>> + Send + use<>> {
|
||||
let mut projection: Vec<String> = bound.roots.clone();
|
||||
projection.push(column.to_string());
|
||||
@@ -481,20 +319,6 @@ async fn fill_stream(
|
||||
.with_row_id()
|
||||
.include_deleted_rows()
|
||||
.project(&projection)?;
|
||||
let output_blob_id = output_is_blob
|
||||
.then(|| {
|
||||
dataset
|
||||
.schema()
|
||||
.field(column)
|
||||
.and_then(|field| u32::try_from(field.id).ok())
|
||||
})
|
||||
.flatten();
|
||||
configure_blob_inputs(
|
||||
&mut scanner,
|
||||
dataset.schema(),
|
||||
bound.as_ref(),
|
||||
output_blob_id,
|
||||
)?;
|
||||
|
||||
let projected = Arc::new(ArrowSchema::new(vec![
|
||||
ArrowSchema::from(dataset.schema())
|
||||
@@ -530,11 +354,6 @@ async fn fill_stream(
|
||||
|
||||
let computed = evaluate(&bound, &evaluation_batch(&batch, &bound, Some(&keep))?)?;
|
||||
let merged = arrow_select::zip::zip(&fill, &computed, existing)?;
|
||||
let merged = if output_is_blob {
|
||||
blob_array_from_binary(&merged, projected.field(0))?
|
||||
} else {
|
||||
merged
|
||||
};
|
||||
Ok(RecordBatch::try_new(projected.clone(), vec![merged])?)
|
||||
}))
|
||||
}
|
||||
@@ -543,12 +362,8 @@ async fn fill_stream(
|
||||
mod tests {
|
||||
use std::sync::Arc;
|
||||
|
||||
use arrow_array::{
|
||||
Array, ArrayRef, Int32Array, LargeBinaryArray, RecordBatch, StructArray, record_batch,
|
||||
};
|
||||
use arrow_schema::Field as ArrowField;
|
||||
use arrow_array::{Int32Array, record_batch};
|
||||
use futures::TryStreamExt;
|
||||
use lance_core::ROW_ID;
|
||||
|
||||
use crate::connect;
|
||||
use crate::query::{ExecutableQuery, QueryBase, Select};
|
||||
@@ -569,8 +384,7 @@ mod tests {
|
||||
.version)
|
||||
}
|
||||
|
||||
async fn read(table: &Table, column: &str) -> Vec<Option<i64>> {
|
||||
use arrow_array::{Array, Int64Array};
|
||||
async fn read(table: &Table, column: &str) -> Vec<Option<i32>> {
|
||||
let batches = table
|
||||
.query()
|
||||
.select(Select::columns(&[column]))
|
||||
@@ -580,19 +394,15 @@ mod tests {
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let mut values: Vec<Option<i64>> = batches
|
||||
let mut values: Vec<Option<i32>> = batches
|
||||
.iter()
|
||||
.flat_map(|batch| {
|
||||
let array = &batch[column];
|
||||
match array.as_any().downcast_ref::<Int32Array>() {
|
||||
Some(ints) => ints.iter().map(|v| v.map(i64::from)).collect::<Vec<_>>(),
|
||||
None => array
|
||||
.as_any()
|
||||
.downcast_ref::<Int64Array>()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.collect::<Vec<_>>(),
|
||||
}
|
||||
batch[column]
|
||||
.as_any()
|
||||
.downcast_ref::<Int32Array>()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.collect();
|
||||
values.sort();
|
||||
@@ -604,117 +414,6 @@ mod tests {
|
||||
table.add(batch).execute().await.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_blob_output_matches_complete_logical_field() {
|
||||
let values: ArrayRef = Arc::new(LargeBinaryArray::from(vec![
|
||||
Some(b"hello".as_slice()),
|
||||
None,
|
||||
]));
|
||||
let field = ArrowField::new(
|
||||
"image",
|
||||
lance_core::datatypes::BLOB_V2_LOGICAL_TYPE.clone(),
|
||||
true,
|
||||
);
|
||||
|
||||
let output = super::blob_array_from_binary(&values, &field).unwrap();
|
||||
assert_eq!(output.data_type(), field.data_type());
|
||||
let output = output.as_any().downcast_ref::<StructArray>().unwrap();
|
||||
assert_eq!(output.column_by_name("position").unwrap().null_count(), 2);
|
||||
assert_eq!(output.column_by_name("size").unwrap().null_count(), 2);
|
||||
}
|
||||
|
||||
/// The gate's reproducer: `b = coalesce(a, 0)` refreshed before `a`
|
||||
/// must not bake zeros from `a`'s placeholder null. It is refused, and
|
||||
/// names the input, until `a` is filled -- after every append too.
|
||||
#[tokio::test]
|
||||
async fn test_dependent_refresh_refuses_an_unfilled_input() {
|
||||
let table = table_with("dependent_refresh_order", vec![1, 2, 3]).await;
|
||||
table
|
||||
.add_columns()
|
||||
.computed("a", "x + 1")
|
||||
.computed("b", "coalesce(a, 0)")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let err = table.refresh_column("b").await.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::InvalidInput { message } if message.contains("refresh 'a' first")),
|
||||
"{err}"
|
||||
);
|
||||
assert_eq!(read(&table, "b").await, vec![None, None, None]);
|
||||
|
||||
assert_eq!(table.refresh_column("a").await.unwrap().rows_filled, 3);
|
||||
assert_eq!(table.refresh_column("b").await.unwrap().rows_filled, 3);
|
||||
assert_eq!(read(&table, "b").await, vec![Some(2), Some(3), Some(4)]);
|
||||
|
||||
append(&table, vec![10]).await;
|
||||
assert!(table.refresh_column("b").await.is_err());
|
||||
table.refresh_column("a").await.unwrap();
|
||||
assert_eq!(table.refresh_column("b").await.unwrap().rows_filled, 1);
|
||||
assert_eq!(
|
||||
table.count_rows(Some("b = 0".to_string())).await.unwrap(),
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
/// Names that need quoting, and a nested input, survive the trip through
|
||||
/// declaration metadata and the dependency check: the recorded inputs
|
||||
/// are matched by name, never re-parsed as SQL.
|
||||
#[tokio::test]
|
||||
async fn test_dependent_refresh_handles_awkward_column_names() {
|
||||
use arrow_array::{Int32Array, StructArray};
|
||||
use arrow_schema::{DataType, Field, Fields};
|
||||
|
||||
let conn = connect("memory://").execute().await.unwrap();
|
||||
let age_fields = Fields::from(vec![Field::new("age", DataType::Int32, true)]);
|
||||
let meta = StructArray::new(
|
||||
age_fields.clone(),
|
||||
vec![Arc::new(Int32Array::from(vec![10, 20])) as _],
|
||||
None,
|
||||
);
|
||||
let schema = Arc::new(arrow_schema::Schema::new(vec![
|
||||
Field::new("camelCase", DataType::Int32, true),
|
||||
Field::new("with-hyphen", DataType::Int32, true),
|
||||
Field::new("meta", DataType::Struct(age_fields), true),
|
||||
]));
|
||||
let batch = arrow_array::RecordBatch::try_new(
|
||||
schema,
|
||||
vec![
|
||||
Arc::new(Int32Array::from(vec![1, 2])) as _,
|
||||
Arc::new(Int32Array::from(vec![100, 200])) as _,
|
||||
Arc::new(meta) as _,
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_table("awkward_names", batch)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
table
|
||||
.add_columns()
|
||||
.computed("y", "`camelCase` * 2")
|
||||
.computed("z", "coalesce(y, 0) + `with-hyphen` + meta.age")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let z = crate::table::computed_columns::computed_columns(
|
||||
table.schema().await.unwrap().as_ref(),
|
||||
)
|
||||
.into_iter()
|
||||
.find(|c| c.name == "z")
|
||||
.unwrap();
|
||||
assert_eq!(z.inputs, vec!["meta.age", "with-hyphen", "y"]);
|
||||
|
||||
let err = table.refresh_column("z").await.unwrap_err();
|
||||
assert!(err.to_string().contains("refresh 'y' first"), "{err}");
|
||||
assert_eq!(table.refresh_column("y").await.unwrap().rows_filled, 2);
|
||||
assert_eq!(table.refresh_column("z").await.unwrap().rows_filled, 2);
|
||||
assert_eq!(read(&table, "z").await, vec![Some(112), Some(224)]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_fills_a_declared_column() {
|
||||
let table = table_with("refresh_fills", vec![1, 2, 3]).await;
|
||||
@@ -952,8 +651,7 @@ mod tests {
|
||||
|
||||
let read_back = read(&table, "doubled").await;
|
||||
assert_eq!(read_back.len(), 20_000);
|
||||
let mut expected: Vec<Option<i64>> =
|
||||
values.iter().map(|v| Some(i64::from(v * 2))).collect();
|
||||
let mut expected: Vec<Option<i32>> = values.iter().map(|v| Some(v * 2)).collect();
|
||||
expected.sort();
|
||||
assert_eq!(read_back, expected);
|
||||
}
|
||||
@@ -1310,366 +1008,4 @@ mod tests {
|
||||
let err = table.refresh_column("embedding").await.unwrap_err();
|
||||
assert!(matches!(err, Error::NotSupported { message } if message.contains("udf")));
|
||||
}
|
||||
|
||||
fn blob_batch(ids: Vec<i32>, payloads: Vec<Option<&[u8]>>) -> RecordBatch {
|
||||
use arrow_array::Int32Array;
|
||||
use arrow_schema::{Field, Schema};
|
||||
|
||||
let mut builder = lance::blob::BlobArrayBuilder::new(payloads.len());
|
||||
for payload in payloads {
|
||||
match payload {
|
||||
Some(payload) => builder.push_bytes(payload).unwrap(),
|
||||
None => builder.push_null().unwrap(),
|
||||
}
|
||||
}
|
||||
RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("id", arrow_schema::DataType::Int32, false),
|
||||
crate::blob("image", true),
|
||||
])),
|
||||
vec![Arc::new(Int32Array::from(ids)), builder.finish().unwrap()],
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn create_blob_table(path: &std::path::Path, batch: RecordBatch) -> Table {
|
||||
let conn = connect(path.to_str().unwrap()).execute().await.unwrap();
|
||||
conn.create_table("blobs", batch).execute().await.unwrap()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_inherits_and_publishes_blob_output() {
|
||||
use arrow_array::UInt64Array;
|
||||
use lance_arrow::{
|
||||
BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, BLOB_INLINE_SIZE_THRESHOLD_META_KEY,
|
||||
};
|
||||
use lance_core::datatypes::BlobKind;
|
||||
|
||||
use crate::table::schema_evolution::FieldMetadataUpdate;
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let table = create_blob_table(
|
||||
tmp.path(),
|
||||
blob_batch(
|
||||
vec![1, 2, 3, 4],
|
||||
vec![Some(b"hello"), Some(b"ab"), Some(b""), None],
|
||||
),
|
||||
)
|
||||
.await;
|
||||
table
|
||||
.add_columns()
|
||||
.computed("image_copy", "image")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table
|
||||
.update_field_metadata(&[FieldMetadataUpdate::new("image_copy")
|
||||
.set(BLOB_INLINE_SIZE_THRESHOLD_META_KEY, "1")
|
||||
.set(BLOB_DEDICATED_SIZE_THRESHOLD_META_KEY, "4")])
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let first_refresh = table.refresh_column("image_copy").await.unwrap();
|
||||
assert_eq!(first_refresh.rows_filled, 3);
|
||||
assert_eq!(
|
||||
table.blob_columns().await.unwrap(),
|
||||
vec!["image".to_string(), "image_copy".to_string()]
|
||||
);
|
||||
|
||||
let batches = table
|
||||
.query()
|
||||
.with_row_id()
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let batch = arrow_select::concat::concat_batches(&batches[0].schema(), &batches).unwrap();
|
||||
assert!(
|
||||
batch
|
||||
.column_by_name("image_copy")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.is::<arrow_array::StructArray>()
|
||||
);
|
||||
let row_ids = batch
|
||||
.column_by_name(ROW_ID)
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap()
|
||||
.values()
|
||||
.to_vec();
|
||||
let original = table.fetch_blobs("image", &row_ids).await.unwrap();
|
||||
let copied = table.fetch_blobs("image_copy", &row_ids).await.unwrap();
|
||||
assert_eq!(original, copied);
|
||||
let ids = batch
|
||||
.column_by_name("id")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<Int32Array>()
|
||||
.unwrap();
|
||||
let files = table
|
||||
.fetch_blob_files("image_copy", &row_ids)
|
||||
.await
|
||||
.unwrap();
|
||||
let mut layouts = ids
|
||||
.values()
|
||||
.iter()
|
||||
.copied()
|
||||
.zip(files)
|
||||
.map(|(id, file)| (id, file.and_then(|file| file.kind())))
|
||||
.collect::<Vec<_>>();
|
||||
layouts.sort_by_key(|(id, _)| *id);
|
||||
assert_eq!(
|
||||
layouts,
|
||||
vec![
|
||||
(1, Some(BlobKind::Dedicated)),
|
||||
(2, Some(BlobKind::Packed)),
|
||||
(3, Some(BlobKind::Inline)),
|
||||
(4, None),
|
||||
]
|
||||
);
|
||||
|
||||
table
|
||||
.add(blob_batch(vec![5], vec![Some(b"appended")]))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table
|
||||
.optimize(crate::table::OptimizeAction::Compact {
|
||||
options: crate::table::CompactionOptions::default(),
|
||||
remap_options: None,
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
table
|
||||
.refresh_column("image_copy")
|
||||
.await
|
||||
.unwrap()
|
||||
.rows_filled,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
table
|
||||
.refresh_column("image_copy")
|
||||
.await
|
||||
.unwrap()
|
||||
.rows_filled,
|
||||
0
|
||||
);
|
||||
|
||||
table.checkout(first_refresh.version).await.unwrap();
|
||||
assert_eq!(table.count_rows(None).await.unwrap(), 4);
|
||||
assert_eq!(
|
||||
table.blob_columns().await.unwrap(),
|
||||
vec!["image".to_string(), "image_copy".to_string()]
|
||||
);
|
||||
table.checkout_latest().await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_inherits_nested_struct_blob_input() {
|
||||
use arrow_array::{Int32Array, StructArray, UInt64Array};
|
||||
use arrow_schema::{DataType, Field, Fields, Schema};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut blob_builder = lance::blob::BlobArrayBuilder::new(2);
|
||||
blob_builder.push_bytes(b"nested").unwrap();
|
||||
blob_builder.push_null().unwrap();
|
||||
let blob_field = crate::blob("image", true);
|
||||
let metadata_fields = Fields::from(vec![blob_field.clone()]);
|
||||
let metadata = StructArray::new(
|
||||
metadata_fields.clone(),
|
||||
vec![blob_builder.finish().unwrap()],
|
||||
None,
|
||||
);
|
||||
let batch = RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("id", DataType::Int32, false),
|
||||
Field::new("metadata", DataType::Struct(metadata_fields), true),
|
||||
])),
|
||||
vec![Arc::new(Int32Array::from(vec![1, 2])), Arc::new(metadata)],
|
||||
)
|
||||
.unwrap();
|
||||
let table = create_blob_table(tmp.path(), batch).await;
|
||||
table
|
||||
.add_columns()
|
||||
.computed("payload_copy", "metadata.image")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table
|
||||
.refresh_column("payload_copy")
|
||||
.await
|
||||
.unwrap()
|
||||
.rows_filled,
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
table.blob_columns().await.unwrap(),
|
||||
vec!["metadata.image".to_string(), "payload_copy".to_string()]
|
||||
);
|
||||
let batches = table
|
||||
.query()
|
||||
.with_row_id()
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let row_ids = batches[0]
|
||||
.column_by_name(ROW_ID)
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap()
|
||||
.values();
|
||||
let payloads = table.fetch_blobs("payload_copy", row_ids).await.unwrap();
|
||||
assert_eq!(payloads.value(0), b"nested");
|
||||
assert!(payloads.is_null(1));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_preserves_list_shape_when_materializing_blob_input() {
|
||||
use arrow_array::{Int32Array, ListArray};
|
||||
use arrow_buffer::{OffsetBuffer, ScalarBuffer};
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut blob_builder = lance::blob::BlobArrayBuilder::new(3);
|
||||
blob_builder.push_bytes(b"a").unwrap();
|
||||
blob_builder.push_bytes(b"bb").unwrap();
|
||||
blob_builder.push_null().unwrap();
|
||||
let item = Arc::new(crate::blob("item", true));
|
||||
let images = ListArray::new(
|
||||
item.clone(),
|
||||
OffsetBuffer::new(ScalarBuffer::from(vec![0, 2, 3])),
|
||||
blob_builder.finish().unwrap(),
|
||||
None,
|
||||
);
|
||||
let batch = RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("id", DataType::Int32, false),
|
||||
Field::new("images", DataType::List(item), true),
|
||||
])),
|
||||
vec![Arc::new(Int32Array::from(vec![1, 2])), Arc::new(images)],
|
||||
)
|
||||
.unwrap();
|
||||
let table = create_blob_table(tmp.path(), batch).await;
|
||||
table
|
||||
.add_columns()
|
||||
.computed("image_payloads", "images")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table
|
||||
.refresh_column("image_payloads")
|
||||
.await
|
||||
.unwrap()
|
||||
.rows_filled,
|
||||
2
|
||||
);
|
||||
let batches = table
|
||||
.query()
|
||||
.select(Select::columns(&["image_payloads"]))
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let output = batches[0]
|
||||
.column_by_name("image_payloads")
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<ListArray>()
|
||||
.unwrap();
|
||||
assert_eq!(output.value_offsets(), &[0, 2, 3]);
|
||||
assert!(output.values().as_any().is::<LargeBinaryArray>());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_refresh_inherits_external_blob_input() {
|
||||
use arrow_array::{Int32Array, StringArray, UInt64Array};
|
||||
use arrow_schema::{DataType, Field, Schema};
|
||||
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let payload = b"external-payload";
|
||||
let path = tmp.path().join("payload.bin");
|
||||
std::fs::write(&path, payload).unwrap();
|
||||
let uri = url::Url::from_file_path(path).unwrap().to_string();
|
||||
let conn = connect(tmp.path().join("db").to_str().unwrap())
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let table = conn
|
||||
.create_empty_table(
|
||||
"external",
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("id", DataType::Int32, false),
|
||||
crate::blob("image", true),
|
||||
])),
|
||||
)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
let batch = RecordBatch::try_new(
|
||||
Arc::new(Schema::new(vec![
|
||||
Field::new("id", DataType::Int32, false),
|
||||
Field::new("image", DataType::Utf8, true),
|
||||
])),
|
||||
vec![
|
||||
Arc::new(Int32Array::from(vec![1])),
|
||||
Arc::new(StringArray::from(vec![Some(uri)])),
|
||||
],
|
||||
)
|
||||
.unwrap();
|
||||
table
|
||||
.add(batch)
|
||||
.allow_external_blob_outside_bases(true)
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
table
|
||||
.add_columns()
|
||||
.computed("payload_copy", "image")
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
table
|
||||
.refresh_column("payload_copy")
|
||||
.await
|
||||
.unwrap()
|
||||
.rows_filled,
|
||||
1
|
||||
);
|
||||
let batches = table
|
||||
.query()
|
||||
.with_row_id()
|
||||
.execute()
|
||||
.await
|
||||
.unwrap()
|
||||
.try_collect::<Vec<_>>()
|
||||
.await
|
||||
.unwrap();
|
||||
let row_ids = batches[0]
|
||||
.column_by_name(ROW_ID)
|
||||
.unwrap()
|
||||
.as_any()
|
||||
.downcast_ref::<UInt64Array>()
|
||||
.unwrap()
|
||||
.values();
|
||||
let payloads = table.fetch_blobs("payload_copy", row_ids).await.unwrap();
|
||||
assert_eq!(payloads.value(0), payload);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,25 @@ fn job_result(name: &str) -> Value {
|
||||
serde_json::from_str::<Value>(&fixture(name)).expect("remote Job fixture")["result"].clone()
|
||||
}
|
||||
|
||||
fn assert_no_secret_values(value: &Value) {
|
||||
match value {
|
||||
Value::Object(values) => {
|
||||
for (key, value) in values {
|
||||
assert!(
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets"
|
||||
),
|
||||
"client canonical value must not model resolved secret material"
|
||||
);
|
||||
assert_no_secret_values(value);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => values.iter().for_each(assert_no_secret_values),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_version_job_result_matches_shared_canonical_golden() {
|
||||
let result = job_result("remote_function_job.json");
|
||||
@@ -28,6 +47,7 @@ fn function_version_job_result_matches_shared_canonical_golden() {
|
||||
assert_eq!(version.name(), "embed");
|
||||
assert_eq!(version.version(), "fv_01K3EXACT");
|
||||
assert_eq!(version.runtime_digest(), "sha256:runtime");
|
||||
assert_eq!(version.required_secrets(), &["HF_TOKEN"]);
|
||||
assert_eq!(
|
||||
version.to_canonical_json().expect("canonical JSON"),
|
||||
fixture("remote_function_version.canonical.json").trim()
|
||||
@@ -142,3 +162,21 @@ fn floating_point_application_literals_are_rejected_consistently() {
|
||||
.contains("floating-point Function literals")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_client_values_contain_secret_names_only() {
|
||||
let result = job_result("remote_function_job.json");
|
||||
let version = FunctionVersion::from_json(&result.to_string()).expect("FunctionVersion result");
|
||||
let canonical: Value = serde_json::from_str(
|
||||
&version
|
||||
.to_canonical_json()
|
||||
.expect("canonical FunctionVersion"),
|
||||
)
|
||||
.expect("canonical JSON");
|
||||
|
||||
assert_eq!(
|
||||
canonical["required_secrets"],
|
||||
serde_json::json!(["HF_TOKEN"])
|
||||
);
|
||||
assert_no_secret_values(&canonical);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ use std::path::PathBuf;
|
||||
|
||||
use lancedb::Error;
|
||||
use lancedb::function::FunctionRegistrationRequest;
|
||||
use serde_json::Value;
|
||||
|
||||
fn fixture(name: &str) -> String {
|
||||
let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
|
||||
@@ -14,6 +15,25 @@ fn fixture(name: &str) -> String {
|
||||
fs::read_to_string(path).expect("fixture must be readable")
|
||||
}
|
||||
|
||||
fn assert_no_secret_values(value: &Value) {
|
||||
match value {
|
||||
Value::Object(values) => {
|
||||
for (key, value) in values {
|
||||
assert!(
|
||||
!matches!(
|
||||
key.as_str(),
|
||||
"secret_value" | "secret_values" | "resolved_secret" | "resolved_secrets"
|
||||
),
|
||||
"registration requests must not model resolved secret material"
|
||||
);
|
||||
assert_no_secret_values(value);
|
||||
}
|
||||
}
|
||||
Value::Array(values) => values.iter().for_each(assert_no_secret_values),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_request_matches_shared_canonical_golden() {
|
||||
let request = FunctionRegistrationRequest::from_json(&fixture(
|
||||
@@ -22,10 +42,33 @@ fn registration_request_matches_shared_canonical_golden() {
|
||||
.expect("registration request");
|
||||
assert_eq!(request.name, "normalize_score");
|
||||
assert_eq!(request.artifact.adapter.kind, "scalar_to_arrow_batch");
|
||||
assert_eq!(request.required_secrets, ["API_TOKEN"]);
|
||||
assert!(request.secret_values.is_empty());
|
||||
assert_eq!(
|
||||
request.to_canonical_json().expect("canonical request"),
|
||||
fixture("remote_function_registration_request.canonical.json").trim()
|
||||
);
|
||||
|
||||
let value: Value =
|
||||
serde_json::from_str(&request.to_canonical_json().expect("canonical request"))
|
||||
.expect("request JSON");
|
||||
assert_no_secret_values(&value);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registration_request_serializes_secret_values_but_redacts_debug_output() {
|
||||
let mut value: Value =
|
||||
serde_json::from_str(&fixture("remote_function_registration_request.json")).unwrap();
|
||||
value["secret_values"] = serde_json::json!({"API_TOKEN": "secret-plaintext"});
|
||||
let request = FunctionRegistrationRequest::from_json(&value.to_string()).unwrap();
|
||||
|
||||
assert_eq!(request.secret_values["API_TOKEN"], "secret-plaintext");
|
||||
let canonical = request.to_canonical_json().unwrap();
|
||||
assert!(canonical.contains("secret-plaintext"));
|
||||
let debug = format!("{request:?}");
|
||||
assert!(debug.contains("API_TOKEN"));
|
||||
assert!(debug.contains("[REDACTED]"));
|
||||
assert!(!debug.contains("secret-plaintext"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -78,12 +78,6 @@
|
||||
"type": "utf8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"arrow_type": "large_utf8",
|
||||
"json": {
|
||||
"type": "large_utf8"
|
||||
}
|
||||
},
|
||||
{
|
||||
"arrow_type": "binary",
|
||||
"json": {
|
||||
@@ -177,21 +171,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arrow_type": "list<large_utf8>",
|
||||
"json": {
|
||||
"type": "list",
|
||||
"fields": [
|
||||
{
|
||||
"name": "item",
|
||||
"nullable": false,
|
||||
"type": {
|
||||
"type": "large_utf8"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arrow_type": "large_list<utf8>",
|
||||
"json": {
|
||||
@@ -207,21 +186,6 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arrow_type": "large_list<large_utf8>",
|
||||
"json": {
|
||||
"type": "large_list",
|
||||
"fields": [
|
||||
{
|
||||
"name": "item",
|
||||
"nullable": false,
|
||||
"type": {
|
||||
"type": "large_utf8"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
"arrow_type": "fixed_size_list<float32, 384>",
|
||||
"json": {
|
||||
@@ -366,4 +330,4 @@
|
||||
"timestamp[us]",
|
||||
"struct<a: int32>"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@
|
||||
},
|
||||
"runtime_digest": "sha256:runtime",
|
||||
"environment_digest": "sha256:environment",
|
||||
"required_secrets": ["HF_TOKEN"],
|
||||
"created_at": "2026-08-21T00:00:00Z"
|
||||
},
|
||||
"future_job": {"trace_id": "trace-1"}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIG5vcm1hbGl6ZV9zY29yZSh2YWx1ZTogZmxvYXQpIC0+IGZsb2F0OgogICAgcmV0dXJuIHZhbHVlIC8gMTAwLjAK","encoding":"base64"},"digest":"sha256:760784bdcef57b802f389b97804cc0b618aae39e86044733451bcd0b13089a7f","entrypoint":"normalize_score","kind":"python_callable"},"name":"normalize_score","runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["numpy>=2"]},"kind":"python","python_version":"3.12"},"signature":{"inputs":[{"arrow_type":"float64","name":"value","nullable":false}],"output":{"arrow_type":"float64","kind":"scalar","nullable":false}}}
|
||||
{"artifact":{"adapter":{"kind":"scalar_to_arrow_batch","version":1},"content":{"data":"ZnJvbSBfX2Z1dHVyZV9fIGltcG9ydCBhbm5vdGF0aW9ucwoKZGVmIG5vcm1hbGl6ZV9zY29yZSh2YWx1ZTogZmxvYXQpIC0+IGZsb2F0OgogICAgcmV0dXJuIHZhbHVlIC8gMTAwLjAK","encoding":"base64"},"digest":"sha256:760784bdcef57b802f389b97804cc0b618aae39e86044733451bcd0b13089a7f","entrypoint":"normalize_score","kind":"python_callable"},"name":"normalize_score","required_secrets":["API_TOKEN"],"runtime":{"env":{"MODE":"test"},"environment":{"kind":"pip","packages":["numpy>=2"]},"kind":"python","python_version":"3.12"},"signature":{"inputs":[{"arrow_type":"float64","name":"value","nullable":false}],"output":{"arrow_type":"float64","kind":"scalar","nullable":false}}}
|
||||
|
||||
Vendored
+4
-1
@@ -39,5 +39,8 @@
|
||||
"env": {
|
||||
"MODE": "test"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required_secrets": [
|
||||
"API_TOKEN"
|
||||
]
|
||||
}
|
||||
|
||||
Vendored
+1
-1
@@ -1 +1 @@
|
||||
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}
|
||||
{"artifact":{"digest":"sha256:code","entrypoint":"embed","kind":"python_callable"},"created_at":"2026-08-21T00:00:00Z","environment_digest":"sha256:environment","name":"embed","required_secrets":["HF_TOKEN"],"runtime":{"env":{"TOKENIZERS_PARALLELISM":"false"},"environment":{"kind":"pip","packages":["sentence-transformers>=3"]},"kind":"python","python_version":"3.12"},"runtime_digest":"sha256:runtime","signature":{"inputs":[{"arrow_type":"utf8","name":"text","nullable":true}],"output":{"arrow_type":"list<float32>","kind":"scalar","nullable":false}},"version":"fv_01K3EXACT"}
|
||||
|
||||
Reference in New Issue
Block a user