Compare commits

..

7 Commits

Author SHA1 Message Date
Gatefixer 1825f6df9e fix(python): validate fork reopen descriptors 2026-08-06 11:10:11 +00:00
Gatefixer 6a1dacb1fe fix(python): coordinate native table state transitions 2026-08-06 10:11:29 +00:00
Gatefixer 62dea8acd8 fix(python): resolve tags before table checkout 2026-08-06 09:12:55 +00:00
Gatefixer ae9e8e8f8d Merge remote-tracking branch 'origin/main' into gatekeeper/fix-3350-1
# Conflicts:
#	python/python/tests/test_db.py
2026-08-06 09:10:43 +00:00
Gatefixer 36e44ab7a9 fix(python): preserve native table reopen state 2026-08-06 08:14:08 +00:00
Gatefixer feccabd739 Merge remote-tracking branch 'origin/main' into gatekeeper/fix-3350-1 2026-08-06 02:56:53 +00:00
Gatefixer 1f3093a51f fix(python): reopen native tables in forked workers 2026-08-05 19:10:41 +00:00
37 changed files with 1054 additions and 1096 deletions
-222
View File
@@ -1,222 +0,0 @@
name: Check doc links
# Checking external links is inherently noisy: third-party sites rate-limit
# automated clients, reject non-browser user agents, and go down temporarily.
# Blocking pull requests on that trades a lot of false failures for very little
# signal, so this runs on a schedule and reports findings in a single tracking
# issue instead of failing anyone's build.
on:
schedule:
- cron: "0 7 * * *"
workflow_dispatch:
# The report lives in one repository-global issue, so runs must not overlap: a
# lookup racing a create produces duplicate issues, and a healthy run closing
# the issue while a failing run only rewrites its body would leave a broken
# report closed. The group is deliberately ref-independent so that a manual
# dispatch serializes against the scheduled run.
concurrency:
group: docs-link-check
cancel-in-progress: false
permissions: {}
env:
REPORT_TITLE: "Docs link checker report"
jobs:
scan:
name: Scan links
runs-on: ubuntu-24.04
# lychee-action is pinned by SHA, but its wrapper downloads the lychee
# release tarball at run time without verifying a digest, and hands the
# resulting binary a GitHub token. Release assets remain replaceable, so
# that binary is confined to a job whose token can only read public
# content; everything that writes runs in the report job below.
permissions:
contents: read
outputs:
exit_code: ${{ steps.lychee.outputs.exit_code }}
steps:
- name: Checkout
uses: actions/checkout@v6
with:
# workflow_dispatch can run from any ref, but the report is
# repository-global. Always measure the default branch so a manual
# run from a topic branch cannot close a report that main warrants,
# or overwrite it with branch-only findings.
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Check links
id: lychee
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with:
# Restricted to http(s) on purpose. Much of docs/src is generated
# 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
# broken on every run.
args: >-
--scheme https
--scheme http
--no-progress
--max-retries 3
--timeout 20
'docs/src/**/*.md'
format: json
output: ./lychee/out.json
jobSummary: false
# The report, not a red build, is the signal for broken links. The
# validation step below still fails the run if the check itself
# breaks.
fail: false
- name: Validate report
# lychee does not reserve exit code 2 for broken links: its CLI
# parser also exits 2 on an invalid option, before any link was
# checked or any report written. Only a parseable report whose
# counts agree with the exit code counts as a link verdict; anything
# else fails here, and the report job below is skipped entirely, so
# the tracking issue is never touched. Exit 2 covers timeouts as
# well as errors, and a timed-out host is exactly the transient
# unavailability this report exists to surface, so both count as
# findings. Requiring total > 0 also catches a glob that silently
# stopped matching any file.
if: steps.lychee.outputs.exit_code == 0 || steps.lychee.outputs.exit_code == 2
env:
EXIT_CODE: ${{ steps.lychee.outputs.exit_code }}
run: |
jq -e --argjson code "$EXIT_CODE" '
(.total > 0) and
(if $code == 0
then .errors == 0 and .timeouts == 0
and (.error_map | length == 0) and (.timeout_map | length == 0)
else (.errors + .timeouts) > 0
and ((.error_map | length) + (.timeout_map | length)) > 0
end)
' ./lychee/out.json
- name: Upload report
if: steps.lychee.outputs.exit_code == 2
uses: actions/upload-artifact@v7
with:
name: link-report
path: ./lychee/out.json
retention-days: 7
report:
name: Update report issue
needs: scan
runs-on: ubuntu-24.04
# Deliberately no checkout: this job needs the report artifact and the
# issues API, not the repository contents.
permissions:
issues: write
env:
EXIT_CODE: ${{ needs.scan.outputs.exit_code }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
steps:
- name: Classify checker result
# lychee exits 0 when every link resolves and 2 when links fail,
# both already cross-checked against the report by the scan job's
# validation step. Anything else (1 runtime, 3 bad config) means the
# check never produced a link verdict, which must surface as a failed
# run rather than be published as "broken documentation links".
run: |
case "$EXIT_CODE" in
0|2)
echo "lychee exit code $EXIT_CODE"
;;
*)
echo "::error::lychee exited with '$EXIT_CODE': the link check did not complete. Leaving the report issue untouched."
exit 1
;;
esac
- name: Find existing report issue
id: report
# Matched on title alone, and through search rather than a listing:
# the issue action applies labels in a separate call after creating the
# issue, so a label filter misses a half-created report, and this
# repository has far more open issues than one listing page holds.
# Closed issues are included because a healthy run closes the report:
# an open-only lookup would forget that identity and the next failing
# run would open a duplicate. The oldest match stays the canonical
# report and is reopened below when links break again.
run: |
match=$(gh issue list --repo "$GITHUB_REPOSITORY" --state all \
--search "in:title \"$REPORT_TITLE\" author:app/github-actions" \
--limit 50 --json number,title,state \
--jq "[.[] | select(.title == \"$REPORT_TITLE\")] | sort_by(.number) | first // empty")
echo "number=$(jq -r '.number // empty' <<<"$match")" >> "$GITHUB_OUTPUT"
echo "state=$(jq -r '.state // empty' <<<"$match")" >> "$GITHUB_OUTPUT"
- name: Download report
if: env.EXIT_CODE == 2
uses: actions/download-artifact@v8
with:
name: link-report
path: ./lychee
- name: Compose report
if: env.EXIT_CODE == 2
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
{
echo "Broken documentation links found by [\`$GITHUB_WORKFLOW\`]($run_url)."
echo
echo "This issue is rewritten by every scheduled run and closed automatically once all links resolve."
echo
echo "Entries can be false positives: some sites rate-limit or block automated clients while working fine in a browser. Confirm before editing the docs, and add persistent offenders to \`--exclude\` in \`.github/workflows/docs-link-check.yml\`."
echo
# Timeouts are reported alongside errors: entries land in
# timeout_map with a status text instead of an HTTP code.
jq -r '
"\(.errors) of \(.total) links failed, \(.timeouts) timed out.",
"",
([(.error_map | to_entries[]), (.timeout_map | to_entries[])]
| group_by(.key)[] |
"### Errors in \(.[0].key)",
"",
(map(.value[])[] | "* [\(.status.code // .status.text // "ERR")] <\(.url)> — \(.status.details // .status.text // "unknown error")"),
"")
' ./lychee/out.json
} > ./lychee/issue.md
- name: Reopen report issue
# A healthy run closes the report, and the issue action below only
# rewrites the body of whatever number it is given. Without an
# explicit reopen, the 2 -> 0 -> 2 sequence would keep rewriting a
# closed issue while links are broken. A CLOSED state implies the
# lookup found a canonical issue, so no separate emptiness check.
if: env.EXIT_CODE == 2 && steps.report.outputs.state == 'CLOSED'
env:
ISSUE_NUMBER: ${{ steps.report.outputs.number }}
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
gh issue reopen "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--comment "Broken documentation links found again in [the latest run]($run_url)."
- name: Report broken links
if: env.EXIT_CODE == 2
uses: peter-evans/create-issue-from-file@fca9117c27cdc29c6c4db3b86c48e4115a786710 # v6.0.0
with:
# Empty on the first failing run, which creates the issue; afterwards
# the same issue is updated in place.
issue-number: ${{ steps.report.outputs.number }}
title: ${{ env.REPORT_TITLE }}
content-filepath: ./lychee/issue.md
labels: documentation
- name: Close report issue once links are healthy
# An OPEN state implies the lookup found a canonical issue; a report
# that is already closed needs nothing.
if: env.EXIT_CODE == 0 && steps.report.outputs.state == 'OPEN'
env:
ISSUE_NUMBER: ${{ steps.report.outputs.number }}
run: |
run_url="$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID"
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" \
--comment "All documentation links resolved in [the latest run]($run_url)."
+6 -8
View File
@@ -296,18 +296,16 @@ jobs:
cargo update -p aws-types --precise 1.3.9
cargo update -p aws-sigv4 --precise 1.3.5
cargo update -p aws-credential-types --precise 1.2.8
# aws-smithy-checksums must stay at or above 0.63.13: OpenDAL's S3
# service needs crc-fast ~1.9, and older releases pin it to ~1.3.
cargo update -p aws-smithy-checksums --precise 0.63.13
cargo update -p aws-smithy-checksums --precise 0.63.9
cargo update -p aws-smithy-runtime --precise 1.9.3
cargo update -p aws-smithy-http --precise 0.62.6
cargo update -p aws-smithy-eventstream --precise 0.60.14
cargo update -p aws-smithy-http --precise 0.62.4
cargo update -p aws-smithy-eventstream --precise 0.60.12
cargo update -p aws-smithy-http-client --precise 1.1.3
cargo update -p aws-smithy-observability --precise 0.1.4
cargo update -p aws-smithy-query --precise 0.60.8
cargo update -p aws-smithy-runtime-api --precise 1.9.3
cargo update -p aws-smithy-async --precise 1.2.7
cargo update -p aws-smithy-types --precise 1.3.6
cargo update -p aws-smithy-runtime-api --precise 1.9.1
cargo update -p aws-smithy-async --precise 1.2.6
cargo update -p aws-smithy-types --precise 1.3.5
cargo update -p aws-smithy-xml --precise 0.60.11
cargo update -p home --precise 0.5.9
- name: cargo +${{ matrix.msrv }} check
Generated
+233 -257
View File
File diff suppressed because it is too large Load Diff
+15 -15
View File
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
rust-version = "1.91.0"
[workspace.dependencies]
lance = { "version" = "=11.0.0-beta.2", default-features = false, "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=11.0.0-beta.2", default-features = false, "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=11.0.0-beta.2", default-features = false, "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { "version" = "=11.0.0-beta.2", "tag" = "v11.0.0-beta.2", "git" = "https://github.com/lance-format/lance.git" }
lance = { "version" = "=10.1.0-beta.1", default-features = false, "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-core = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-datagen = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-file = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-io = { "version" = "=10.1.0-beta.1", default-features = false, "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-index = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-linalg = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-namespace-impls = { "version" = "=10.1.0-beta.1", default-features = false, "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-table = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-testing = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-datafusion = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-encoding = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
lance-arrow = { "version" = "=10.1.0-beta.1", "tag" = "v10.1.0-beta.1", "git" = "https://github.com/lance-format/lance.git" }
ahash = "0.8"
# Note that this one does not include pyarrow
arrow = { version = "58.0.0", optional = false }
@@ -52,7 +52,7 @@ env_logger = "0.11"
half = { "version" = "2.7.1", default-features = false, features = [
"num-traits",
] }
futures = "0.3"
futures = "0"
log = "0.4"
metrics = "0.24"
metrics-util = "0.19"
+1 -1
View File
@@ -31,7 +31,7 @@ is also an [asynchronous API client](#connections-asynchronous).
## Namespaces (Synchronous)
A namespace-backed connection resolves tables through a
[Lance namespace](https://lance-format.github.io/lance-namespace/) service instead of
[Lance namespace](https://lancedb.github.io/lance-namespace/) service instead of
listing a storage directory.
::: lancedb.connect_namespace
+1 -1
View File
@@ -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-beta.2</lance-core.version>
<lance-core.version>10.1.0-beta.1</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>
-60
View File
@@ -11,11 +11,8 @@ import {
Float16,
Float32,
Float64,
Int32,
Schema,
Utf8,
fromDataToBuffer,
tableFromIPC,
} from "../lancedb/arrow";
import { EmbeddingFunction, LanceSchema } from "../lancedb/embedding";
import { getRegistry, register } from "../lancedb/embedding/registry";
@@ -187,63 +184,6 @@ describe("embedding functions", () => {
const vector0 = JSON.parse(JSON.stringify(arr[0].vector));
expect(vector0).toEqual([1, 2, 3]);
});
it("should append generated vectors to a non-nullable schema", async () => {
@register("non_nullable_schema_test")
class MockEmbeddingFunction extends EmbeddingFunction<string> {
ndims() {
return 3;
}
embeddingDataType(): Float {
return new Float64();
}
async computeSourceEmbeddings(data: string[]) {
return data.map(() => [1, 2, 3]);
}
}
const schema = new Schema([
new Field("id", new Int32()),
new Field("text", new Utf8()),
new Field("type", new Utf8()),
new Field(
"vector",
new FixedSizeList(3, new Field("item", new Float64())),
),
]);
const func = new MockEmbeddingFunction();
const db = await connect(tmpDir.name);
const table = await db.createEmptyTable("test_non_nullable", schema, {
embeddingFunction: {
function: func,
sourceColumn: "text",
},
});
const data = [
{ id: 1, text: "Carrot", type: "vegetable" },
{ id: 2, text: "Apple", type: "fruit" },
];
const buffer = await fromDataToBuffer(
data,
undefined,
await table.schema(),
);
const generatedTable = tableFromIPC(buffer);
const vectorField = generatedTable.schema.fields.find(
(field) => field.name === "vector",
);
expect(vectorField?.nullable).toBe(false);
await table.add(data);
const rows = await table.query().toArray();
expect(rows).toHaveLength(2);
for (const row of rows) {
expect([...row.vector]).toEqual([1, 2, 3]);
}
});
it("should error when appending to a table with an unregistered embedding function", async () => {
@register("mock")
class MockEmbeddingFunction extends EmbeddingFunction<string> {
-14
View File
@@ -1,14 +0,0 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import packageJson = require("../package.json");
describe("package metadata", () => {
it("requires Node.js type declarations compatible with the runtime", () => {
expect(packageJson.engines.node).toBe(">= 18");
expect(packageJson.peerDependencies["@types/node"]).toBe(">=18");
expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({
optional: true,
});
});
});
-75
View File
@@ -110,81 +110,6 @@ describe("Query outputSchema", () => {
});
});
describe("Search pagination", () => {
let tmpDir: tmp.DirResult;
let table: Table;
beforeEach(async () => {
tmpDir = tmp.dirSync({ unsafeCleanup: true });
const db = await connect(tmpDir.name);
const schema = new Schema([
new Field("id", new Int64(), false),
new Field("text", new Utf8(), false),
new Field(
"vector",
new FixedSizeList(2, new Field("item", new Float32())),
false,
),
]);
const data = makeArrowTable(
[
{ id: 1n, text: "common", vector: [0, 0] },
{ id: 2n, text: "common common", vector: [1, 1] },
{ id: 3n, text: "common common common", vector: [2, 2] },
{ id: 4n, text: "common common common common", vector: [3, 3] },
],
{ schema },
);
table = await db.createTable("test", data);
});
afterEach(() => {
tmpDir.removeCallback();
});
it("applies offset after the vector search limit", async () => {
const allResults = await table
.vectorSearch([0, 0])
.select(["id"])
.limit(4)
.toArray();
const secondPage = await table
.vectorSearch([0, 0])
.select(["id"])
.limit(2)
.offset(2)
.toArray();
expect(allResults).toHaveLength(4);
expect(secondPage).toHaveLength(2);
expect(secondPage.map((row) => row.id)).toEqual(
allResults.slice(2, 4).map((row) => row.id),
);
});
it("applies offset after the full-text search limit", async () => {
await table.createIndex("text", { config: Index.fts() });
const allResults = await table
.search("common", "fts")
.select(["id"])
.limit(4)
.toArray();
const secondPage = await table
.search("common", "fts")
.select(["id"])
.limit(2)
.offset(2)
.toArray();
expect(allResults).toHaveLength(4);
expect(secondPage).toHaveLength(2);
expect(secondPage.map((row) => row.id)).toEqual(
allResults.slice(2, 4).map((row) => row.id),
);
});
});
describe("Query orderBy", () => {
let tmpDir: tmp.DirResult;
let table: Table;
-6
View File
@@ -55,13 +55,7 @@
"openai": "4.29.2"
},
"peerDependencies": {
"@types/node": ">=18",
"apache-arrow": ">=15.0.0 <=18.1.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/@aws-crypto/crc32": {
-6
View File
@@ -101,12 +101,6 @@
"openai": "4.29.2"
},
"peerDependencies": {
"@types/node": ">=18",
"apache-arrow": ">=15.0.0 <=18.1.0"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
}
+1 -2
View File
@@ -60,7 +60,7 @@ tests = [
"pytest-asyncio>=0.21",
"duckdb>=0.9.0",
"pytz>=2023.3",
"polars>=0.19, <=1.32.3",
"polars>=0.19, <=1.3.0",
"pyarrow<25",
"pyarrow-stubs>=16.0",
"pylance==9.0.0rc1",
@@ -140,7 +140,6 @@ include = [
"python/lancedb/remote/errors.py",
"python/lancedb/embeddings/__init__.py",
"python/lancedb/_lancedb.pyi",
"python/type_tests/connect.py",
]
exclude = ["python/tests/"]
pythonVersion = "3.13"
+1 -1
View File
@@ -804,7 +804,7 @@ class LanceDBConnection(DBConnection):
"manifest_enabled": self._manifest_enabled,
"namespace_client_properties": self._namespace_client_properties,
"read_consistency_interval_seconds": (
rci.total_seconds() if rci else None
rci.total_seconds() if rci is not None else None
),
}
)
+1 -5
View File
@@ -183,11 +183,7 @@ class EmbeddingFunction(BaseModel, ABC):
def VectorField(self, **kwargs):
"""
Creates a pydantic Field that can automatically annotate
the target vector column for this embedding function.
The field can be annotated as ``list[float]`` for compatibility with
static type checkers. LanceDB will infer the fixed vector dimension from
this embedding function.
the target vector column for this embedding function
"""
return Field(json_schema_extra={"vector_column_for": self}, **kwargs)
+17 -1
View File
@@ -482,6 +482,16 @@ class LanceNamespaceDBConnection(DBConnection):
def serialize(self) -> str:
import json
if (
self._namespace_client_impl is None
or self._namespace_client_properties is None
):
raise ValueError(
"Cannot serialize a namespace connection constructed from an "
"opaque namespace client. Pass namespace_client_impl and "
"namespace_client_properties when constructing the connection."
)
return json.dumps(
{
"connection_type": "namespace",
@@ -493,7 +503,7 @@ class LanceNamespaceDBConnection(DBConnection):
"storage_options": self.storage_options or None,
"read_consistency_interval_seconds": (
self.read_consistency_interval.total_seconds()
if self.read_consistency_interval
if self.read_consistency_interval is not None
else None
),
}
@@ -569,6 +579,7 @@ class LanceNamespaceDBConnection(DBConnection):
self,
name,
namespace_path=namespace_path,
storage_options=storage_options,
namespace_client=self._namespace_client,
pushdown_operations=self._namespace_client_pushdown_operations,
route_pushdown_to_rust=self._route_pushdown_to_rust,
@@ -607,6 +618,8 @@ class LanceNamespaceDBConnection(DBConnection):
self,
name,
namespace_path=namespace_path,
storage_options=storage_options,
index_cache_size=index_cache_size,
namespace_client=self._namespace_client,
pushdown_operations=self._namespace_client_pushdown_operations,
route_pushdown_to_rust=self._route_pushdown_to_rust,
@@ -899,10 +912,13 @@ class LanceNamespaceDBConnection(DBConnection):
self,
name,
namespace_path=namespace_path,
storage_options=storage_options,
index_cache_size=index_cache_size,
location=table_uri,
namespace_client=namespace_client,
managed_versioning=managed_versioning,
pushdown_operations=self._namespace_client_pushdown_operations,
route_pushdown_to_rust=self._route_pushdown_to_rust,
_async=async_table,
)
+7
View File
@@ -591,6 +591,13 @@ class Permutation:
then the first split will be used.
"""
assert base_table is not None, "base_table is required"
# A PyTorch fork worker may construct its Permutation lazily from a
# table opened in the parent process. Reopen that table before the
# Rust reader clones its object-store clients and connection pools.
if hasattr(base_table, "_ensure_open"):
base_table._ensure_open()
if permutation_table is not None and hasattr(permutation_table, "_ensure_open"):
permutation_table._ensure_open()
if split is not None:
if permutation_table is None:
raise ValueError(
-1
View File
@@ -1 +0,0 @@
-49
View File
@@ -99,17 +99,6 @@ def Vector(
... pa.field("url", pa.utf8(), False),
... pa.field("embeddings", pa.list_(pa.float32(), 768))
... ])
Notes
-----
``Vector`` creates a type dynamically, so calls such as ``Vector(768)`` are
not valid static type annotations. For an embedding field, use the standard
``list[float]`` annotation when running mypy; ``VectorField`` supplies the
fixed dimension to LanceDB::
class MyModel(LanceModel):
text: str = embeddings.SourceField()
vector: list[float] = embeddings.VectorField()
"""
# TODO: make a public parameterized type.
@@ -164,16 +153,6 @@ def Vector(
return FixedSizeList
def _raise_bare_vector_error(*_args):
raise TypeError("Vector must be parameterized with a dimension, e.g. Vector(128).")
# Pydantic v1 and v2 otherwise treat the bare Vector factory as a field validator
# and inspect its signature, which produces misleading errors about internal types.
setattr(Vector, "__get_validators__", _raise_bare_vector_error)
setattr(Vector, "__get_pydantic_core_schema__", _raise_bare_vector_error)
def MultiVector(
dim: int, value_type: pa.DataType = pa.float32(), nullable: bool = True
) -> Type:
@@ -390,10 +369,6 @@ def _unwrap_optional_annotation(annotation: Any) -> Any | None:
def _pydantic_to_arrow_type(field: FieldInfo) -> pa.DataType:
"""Convert a Pydantic FieldInfo to Arrow DataType"""
embedding_vector_type = _embedding_vector_to_arrow_type(field)
if embedding_vector_type is not None:
return embedding_vector_type
unwrapped = _unwrap_optional_annotation(field.annotation)
if unwrapped is not None:
return _pydantic_type_to_arrow_type(unwrapped, field)
@@ -407,32 +382,8 @@ def _pydantic_to_arrow_type(field: FieldInfo) -> pa.DataType:
return _pydantic_type_to_arrow_type(field.annotation, field)
def _embedding_vector_to_arrow_type(field: FieldInfo) -> pa.DataType | None:
"""Infer a fixed-size vector type from ``VectorField`` metadata."""
if not _is_embedding_vector_annotation(field):
return None
function = get_extras(field, "vector_column_for")
return pa.list_(pa.float32(), function.ndims())
def _is_embedding_vector_annotation(field: FieldInfo) -> bool:
if get_extras(field, "vector_column_for") is None:
return False
annotation = _unwrap_optional_annotation(field.annotation)
if annotation is None:
annotation = field.annotation
origin = getattr(annotation, "__origin__", None)
args = getattr(annotation, "__args__", ())
return origin is list and args == (float,)
def is_nullable(field: FieldInfo) -> bool:
"""Check if a Pydantic FieldInfo is nullable."""
if _is_embedding_vector_annotation(field):
return True
if _unwrap_optional_annotation(field.annotation) is not None:
return True
if isinstance(field.annotation, (_GenericAlias, GenericAlias)):
+306 -32
View File
@@ -6,6 +6,8 @@ from __future__ import annotations
import asyncio
import inspect
import deprecation
import os
import threading
import warnings
from abc import ABC, abstractmethod
from dataclasses import dataclass
@@ -108,11 +110,6 @@ def _should_push_down_query_table(
return namespace_client is not None and "QueryTable" in pushdown_operations
def _polars_predicate_pushdown_barrier(frame: Any) -> Any:
"""Return a Polars frame unchanged while blocking predicate pushdown."""
return frame
_MODEL_BACKED_TOKENIZER_PREFIXES = ("jieba", "lindera")
_MODEL_BACKED_TOKENIZER_ERRORS = (
"unknown base tokenizer",
@@ -168,7 +165,7 @@ def _maybe_add_fts_error_note(
if TYPE_CHECKING:
from .db import LanceDBConnection
from .db import DBConnection, LanceDBConnection
from ._lancedb import (
Table as LanceDBTable,
OptimizeStats,
@@ -869,18 +866,12 @@ class Table(ABC):
"""
raise NotImplementedError
def to_polars(self, **kwargs) -> "pl.LazyFrame":
"""Return the table as a Polars LazyFrame.
Note
----
The Polars streaming engine is not supported because it does not currently
implement Python PyArrow dataset scans. Use the default engine when collecting
this LazyFrame.
def to_polars(self, **kwargs) -> "pl.DataFrame":
"""Return the table as a polars.DataFrame.
Returns
-------
polars.LazyFrame
polars.DataFrame
"""
raise NotImplementedError
@@ -2116,6 +2107,23 @@ class Table(ABC):
"""
@dataclass
class _LanceTableReopenState:
"""Process-independent coordinates for reopening a native table."""
connection_state: Optional[str]
can_reopen_after_fork: bool
fork_reopen_error: Optional[str]
name: str
namespace_path: List[str]
storage_options: Optional[Dict[str, str]]
index_cache_size: Optional[int]
location: Optional[str]
managed_versioning: Optional[bool]
branch: Optional[str]
checkout_version: Optional[int]
class LanceTable(Table):
"""
A table in a LanceDB database.
@@ -2150,7 +2158,10 @@ class LanceTable(Table):
namespace_path = []
self._conn = connection
self._namespace_path = namespace_path
self._storage_options = storage_options
self._index_cache_size = index_cache_size
self._location = location # Store location for use in _dataset_path
self._managed_versioning = managed_versioning
self._namespace_client = namespace_client
self._pushdown_operations = pushdown_operations or set()
# When the connection built the namespace client natively (e.g. an
@@ -2175,9 +2186,206 @@ class LanceTable(Table):
managed_versioning=managed_versioning,
)
)
self._initialize_reopen_state(name)
def _initialize_reopen_state(self, name: str) -> None:
"""Capture the state needed to replace inherited native handles."""
self._name = name
self._pid = os.getpid()
self._native_state_guard = (self._pid, threading.RLock())
# A native table owns object-store clients and connection pools. Those
# handles must not be used after fork, so retain a process-independent
# connection description while it is still safe to inspect the parent
# connection. Connections without reconstructible metadata are not
# safe to reuse in a forked child, so retain a clear diagnostic rather
# than advertising them as reopenable based on JSON encoding alone.
try:
connection_uri: Optional[str] = self._conn.uri
except Exception:
connection_uri = None
fork_reopen_error: Optional[str] = None
try:
connection_state: Optional[str] = self._conn.serialize()
can_reopen_after_fork = connection_uri is not None and not (
connection_uri.startswith("memory://")
)
except Exception as error:
connection_state = None
can_reopen_after_fork = False
if connection_uri is not None and not connection_uri.startswith(
"memory://"
):
fork_reopen_error = (
f"Cannot reopen table {name!r} in a forked process: {error}"
)
self._reopen_state = _LanceTableReopenState(
connection_state=connection_state,
can_reopen_after_fork=can_reopen_after_fork,
fork_reopen_error=fork_reopen_error,
name=name,
namespace_path=list(self._namespace_path),
storage_options=(
dict(self._storage_options)
if self._storage_options is not None
else None
),
index_cache_size=self._index_cache_size,
location=self._location,
managed_versioning=self._managed_versioning,
branch=self._table.current_branch(),
checkout_version=None,
)
@property
def _connection_state(self) -> Optional[str]:
"""Serialized connection retained for worker reconstruction."""
return self._reopen_state.connection_state
@property
def _can_reopen_after_fork(self) -> bool:
return self._reopen_state.can_reopen_after_fork
@property
def _branch(self) -> Optional[str]:
state = getattr(self, "_reopen_state", None)
if state is not None:
return state.branch
return getattr(self, "_legacy_branch", None)
@_branch.setter
def _branch(self, value: Optional[str]) -> None:
state = getattr(self, "_reopen_state", None)
if state is not None:
state.branch = value
else:
self._legacy_branch = value
@property
def _checkout_version(self) -> Optional[int]:
state = getattr(self, "_reopen_state", None)
if state is not None:
return state.checkout_version
return getattr(self, "_legacy_checkout_version", None)
@_checkout_version.setter
def _checkout_version(self, value: Optional[int]) -> None:
state = getattr(self, "_reopen_state", None)
if state is not None:
state.checkout_version = value
else:
self._legacy_checkout_version = value
def _native_state_lock(self):
"""Return the per-process lock coordinating native mode and reopen state."""
pid = os.getpid()
guard = getattr(self, "_native_state_guard", None)
if guard is None:
candidate = (pid, threading.RLock())
guard = self.__dict__.setdefault("_native_state_guard", candidate)
elif guard[0] != pid:
# A lock inherited while another parent thread held it cannot be
# safely acquired in the child. Child state starts single-threaded,
# so replace it before coordinating the first reopen.
guard = (pid, threading.RLock())
self._native_state_guard = guard
return guard[1]
@classmethod
def _open_from_reopen_state(
cls,
connection: "DBConnection",
state: "_LanceTableReopenState",
) -> "LanceTable":
"""Open a table from its complete process-independent descriptor."""
async_connection = getattr(connection, "_conn", None)
if async_connection is None:
async_connection = connection._inner
namespace_client = getattr(connection, "_namespace_client", None)
async_table = LOOP.run(
async_connection.open_table(
state.name,
namespace_path=state.namespace_path,
storage_options=state.storage_options,
index_cache_size=state.index_cache_size,
location=state.location,
namespace_client=namespace_client,
managed_versioning=state.managed_versioning,
)
)
table = cls(
connection,
state.name,
namespace_path=state.namespace_path,
storage_options=state.storage_options,
index_cache_size=state.index_cache_size,
location=state.location,
namespace_client=namespace_client,
managed_versioning=state.managed_versioning,
pushdown_operations=getattr(
connection, "_namespace_client_pushdown_operations", None
),
route_pushdown_to_rust=getattr(
connection, "_route_pushdown_to_rust", False
),
_async=async_table,
)
if state.branch is not None:
table = table.branches.checkout(state.branch, state.checkout_version)
elif state.checkout_version is not None:
table.checkout(state.checkout_version)
return table
def _ensure_open(self) -> None:
"""Reopen native table handles inherited from another process."""
with self._native_state_lock():
pid = os.getpid()
if getattr(self, "_pid", pid) == pid:
return
state = getattr(self, "_reopen_state", None)
fork_reopen_error = getattr(state, "fork_reopen_error", None)
if fork_reopen_error is not None:
raise RuntimeError(fork_reopen_error)
if (
state is None
or not state.can_reopen_after_fork
or state.connection_state is None
):
# In-memory and opaque Rust-only connections cannot be recreated
# from connection metadata. Their local handles retain the prior
# best-effort fork behavior.
self._pid = pid
return
from lancedb import deserialize_conn
connection = deserialize_conn(state.connection_state, for_worker=True)
reopened = self._open_from_reopen_state(
connection,
state,
)
# Keep this Python object stable because user datasets commonly retain
# it across fork. Replace every process-bound component with the fresh
# child's equivalent.
self._conn = reopened._conn
self._table = reopened._table
self._namespace_client = reopened._namespace_client
self._pushdown_operations = reopened._pushdown_operations
self._route_pushdown_to_rust = reopened._route_pushdown_to_rust
self._reopen_state = reopened._reopen_state
self._pid = pid
@property
def name(self) -> str:
if hasattr(self, "_name"):
return self._name
# Preserve compatibility with lightweight / legacy instances that
# were constructed without running ``LanceTable.__init__``.
return self._table.name
@property
@@ -2394,18 +2602,75 @@ class LanceTable(Table):
def _wrap_branch_handle(
self, async_table: "AsyncTable", version: Optional[int] = None
) -> "LanceTable":
# version is unused locally: the pin already lives on async_table and a
# local handle is not reopened via a serialized connection.
return LanceTable(
table = LanceTable(
self._conn,
async_table.name,
namespace_path=self._namespace_path,
storage_options=self._storage_options,
index_cache_size=self._index_cache_size,
namespace_client=self._namespace_client,
pushdown_operations=self._pushdown_operations,
route_pushdown_to_rust=self._route_pushdown_to_rust,
location=self._location,
managed_versioning=self._managed_versioning,
_async=async_table,
)
table._checkout_version = version
return table
def _resolve_checkout_version(self, version: Union[int, str]) -> int:
if isinstance(version, int):
return version
try:
return self.tags.get_version(version)
except RuntimeError as err:
# Native checkout historically exposes an unknown tag as ValueError.
# Preserve that contract while resolving tags before mutating the table.
if "Ref not found" in str(err) and "does not exist" in str(err):
raise ValueError(str(err)) from err
raise
async def _commit_native_state(
self,
transition,
version: Optional[int],
started: threading.Event,
finished: threading.Event,
):
"""Commit a native transition and its fork coordinate as one task."""
started.set()
try:
task = asyncio.ensure_future(transition)
try:
result = await asyncio.shield(task)
except asyncio.CancelledError:
# BackgroundEventLoop cancels its submitted task when the
# waiting caller is interrupted. Let an already-started native
# transition reach its authoritative terminal state before the
# per-table boundary is released.
result = await task
self._checkout_version = version
raise
self._checkout_version = version
return result
finally:
finished.set()
def _run_native_state_transition(self, transition, version: Optional[int]):
started = threading.Event()
finished = threading.Event()
try:
return LOOP.run(
self._commit_native_state(transition, version, started, finished)
)
except BaseException:
if started.is_set():
while not finished.is_set():
try:
finished.wait()
except BaseException: # noqa: PERF203
continue
raise
def checkout(self, version: Union[int, str]):
"""Checkout a version of the table. This is an in-place operation.
@@ -2443,7 +2708,14 @@ class LanceTable(Table):
vector type
0 [1.1, 0.9] vector
"""
LOOP.run(self._table.checkout(version))
# Resolve tags before mutating the native handle. This leaves the live
# handle and reopen descriptor aligned if tag lookup fails, and avoids a
# second fallible version lookup after checkout succeeds.
with self._native_state_lock():
resolved_version = self._resolve_checkout_version(version)
self._run_native_state_transition(
self._table.checkout(resolved_version), resolved_version
)
def checkout_latest(self):
"""Checkout the latest version of the table. This is an in-place operation.
@@ -2451,7 +2723,8 @@ class LanceTable(Table):
The table will be set back into standard mode, and will track the latest
version of the table.
"""
LOOP.run(self._table.checkout_latest())
with self._native_state_lock():
self._run_native_state_transition(self._table.checkout_latest(), None)
def restore(self, version: Optional[Union[int, str]] = None):
"""Restore a version of the table. This is an in-place operation.
@@ -2497,9 +2770,13 @@ class LanceTable(Table):
>>> len(table.list_versions())
4
"""
if version is not None:
LOOP.run(self._table.checkout(version))
LOOP.run(self._table.restore())
with self._native_state_lock():
if version is not None:
resolved_version = self._resolve_checkout_version(version)
self._run_native_state_transition(
self._table.checkout(resolved_version), resolved_version
)
self._run_native_state_transition(self._table.restore(), None)
def count_rows(self, filter: Optional[str] = None) -> int:
return LOOP.run(self._table.count_rows(filter))
@@ -2580,9 +2857,6 @@ class LanceTable(Table):
2. Currently we've disabled push-down of the filters from polars
because polars pushdown into pyarrow uses pyarrow compute
expressions rather than SQl strings (which LanceDB supports)
3. The Polars streaming engine is not supported because it does not
currently implement Python PyArrow dataset scans. Use the default
engine when collecting this LazyFrame.
Returns
-------
@@ -2591,12 +2865,8 @@ class LanceTable(Table):
from lancedb.integrations.pyarrow import PyarrowDatasetAdapter
dataset = PyarrowDatasetAdapter(self)
# Polars 1.32's non-PyArrow callback path passes batch_size twice. Keep
# the compatible PyArrow path, but block predicates because this adapter
# cannot translate PyArrow expressions into LanceDB filters.
return pl.scan_pyarrow_dataset(dataset, batch_size=batch_size).map_batches(
_polars_predicate_pushdown_barrier,
predicate_pushdown=False,
return pl.scan_pyarrow_dataset(
dataset, allow_pyarrow_filter=False, batch_size=batch_size
)
# New unified API overload
@@ -3617,7 +3887,9 @@ class LanceTable(Table):
self = cls.__new__(cls)
self._conn = db
self._namespace_path = namespace_path
self._index_cache_size = None
self._location = location
self._managed_versioning = None
self._namespace_client = namespace_client
self._pushdown_operations = pushdown_operations or set()
self._route_pushdown_to_rust = route_pushdown_to_rust
@@ -3645,6 +3917,7 @@ class LanceTable(Table):
enable_v2_manifest_paths
)
self._storage_options = storage_options
self._table = LOOP.run(
self._conn._conn.create_table(
name,
@@ -3661,6 +3934,7 @@ class LanceTable(Table):
namespace_client=namespace_client,
)
)
self._initialize_reopen_state(name)
return self
def delete(self, where: Union[str, Expr]) -> DeleteResult:
+12 -5
View File
@@ -2,11 +2,11 @@
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import json
import inspect
import re
import sys
from datetime import timedelta
from importlib import resources
import os
from types import SimpleNamespace
@@ -19,10 +19,6 @@ from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from lancedb.pydantic import LanceModel, Vector
def test_package_includes_pep_561_marker():
assert resources.files(lancedb).joinpath("py.typed").is_file()
def test_basic(tmp_path):
db = lancedb.connect(tmp_path)
@@ -106,6 +102,17 @@ def test_read_consistency_interval_does_not_use_background_loop(tmp_path, monkey
assert db_from_inner.read_consistency_interval == consistency_interval
def test_serialize_preserves_zero_read_consistency_interval(tmp_path):
db = lancedb.connect(tmp_path, read_consistency_interval=timedelta(0))
table = db.create_table("items", pa.table({"x": [1]}))
encoded = json.loads(table._connection_state)
assert encoded["read_consistency_interval_seconds"] == 0.0
restored = lancedb.deserialize_conn(table._connection_state)
assert restored.read_consistency_interval == timedelta(0)
def test_ingest_pd(tmp_path):
db = lancedb.connect(tmp_path)
+103 -1
View File
@@ -6,9 +6,13 @@
import tempfile
import shutil
import importlib
import multiprocessing as mp
import sys
from datetime import timedelta
import pytest
import pyarrow as pa
import lancedb
from lance_namespace import connect as namespace_connect
from lance_namespace.errors import NamespaceNotEmptyError, TableNotFoundError
from lancedb.namespace import _MAX_QUERY_K
from lancedb.table import AsyncTable, LanceTable
@@ -72,6 +76,16 @@ def _namespace_lance_table(namespace_client: _NamespaceClient) -> LanceTable:
return table
def _direct_namespace_fork_child(table, result_queue):
from lancedb.permutation import Permutation
try:
permutation = Permutation.identity(table)
result_queue.put(("ok", permutation.num_rows))
except Exception as error:
result_queue.put((type(error).__name__, str(error)))
class TestNamespaceConnection:
"""Test namespace-based LanceDB connection using DirectoryNamespace."""
@@ -419,7 +433,95 @@ class TestNamespaceConnection:
pa.field("vector", pa.list_(pa.float32(), 2)),
]
)
db.create_table("test_table", schema=schema, storage_options=table_opts)
created = db.create_table(
"test_table", schema=schema, storage_options=table_opts
)
assert created._storage_options == table_opts
opened = db.open_table(
"test_table",
storage_options={"allow_http": "true"},
index_cache_size=17,
)
assert opened._storage_options == {"allow_http": "true"}
assert opened._index_cache_size == 17
opened._pid = -1
opened._ensure_open()
assert opened.count_rows() == 0
def test_serialize_preserves_zero_read_consistency_interval(self):
db = lancedb.connect_namespace(
"dir",
{"root": self.temp_dir},
read_consistency_interval=timedelta(0),
)
restored = lancedb.deserialize_conn(db.serialize())
assert restored.read_consistency_interval == timedelta(0)
@pytest.mark.skipif(
sys.platform != "linux",
reason="fork() is only supported safely for this test on Linux",
)
def test_direct_namespace_with_descriptor_reopens_after_fork(self):
properties = {"root": self.temp_dir}
namespace = namespace_connect("dir", properties)
db = lancedb.LanceNamespaceDBConnection(
namespace,
namespace_client_impl="dir",
namespace_client_properties=properties,
)
table = db.create_table("items", pa.table({"id": [1]}))
ctx = mp.get_context("fork")
result_queue = ctx.Queue()
process = ctx.Process(
target=_direct_namespace_fork_child,
args=(table, result_queue),
)
process.start()
process.join(10)
if process.is_alive():
process.terminate()
process.join(5)
pytest.fail("Direct namespace table hung while reopening after fork")
assert process.exitcode == 0
assert result_queue.get(timeout=2) == ("ok", 1)
@pytest.mark.skipif(
sys.platform != "linux",
reason="fork() is only supported safely for this test on Linux",
)
def test_opaque_direct_namespace_reports_unsupported_fork(self):
namespace = namespace_connect("dir", {"root": self.temp_dir})
db = lancedb.LanceNamespaceDBConnection(namespace)
table = db.create_table("items", pa.table({"id": [1]}))
with pytest.raises(ValueError, match="opaque namespace client"):
db.serialize()
assert not table._can_reopen_after_fork
ctx = mp.get_context("fork")
result_queue = ctx.Queue()
process = ctx.Process(
target=_direct_namespace_fork_child,
args=(table, result_queue),
)
process.start()
process.join(10)
if process.is_alive():
process.terminate()
process.join(5)
pytest.fail("Opaque namespace table hung after fork")
assert process.exitcode == 0
error_type, message = result_queue.get(timeout=2)
assert error_type == "RuntimeError"
assert "Cannot reopen table 'items' in a forked process" in message
assert "namespace_client_impl and namespace_client_properties" in message
def test_namespace_operations(self):
"""Test namespace management operations."""
-31
View File
@@ -9,7 +9,6 @@ from typing import List, Optional, Tuple
import pyarrow as pa
import pydantic
import pytest
from lancedb.conftest import MockTextEmbeddingFunction
from lancedb.pydantic import (
PYDANTIC_VERSION,
LanceModel,
@@ -416,36 +415,6 @@ def test_nullable_vector():
assert schema == pa.schema([pa.field("vec", pa.list_(pa.float32(), 16), True)])
def test_bare_vector_raises_clear_error():
namespace = {
"__name__": "test_model_without_pyarrow",
"LanceModel": LanceModel,
"Vector": Vector,
}
with pytest.raises(TypeError, match=r"Vector must be parameterized.*Vector\(128\)"):
exec("class TestModel(LanceModel):\n vector: Vector", namespace)
def test_embedding_vector_list_annotation():
embedding = MockTextEmbeddingFunction.create()
class StaticTypingModel(LanceModel):
text: str = embedding.SourceField()
vector: list[float] = embedding.VectorField()
schema = pydantic_to_schema(StaticTypingModel)
assert schema == pa.schema(
[
pa.field("text", pa.utf8(), False),
pa.field("vector", pa.list_(pa.float32(), embedding.ndims()), True),
]
)
model = StaticTypingModel(text="hello", vector=[0.0] * embedding.ndims())
assert model.vector == [0.0] * embedding.ndims()
def test_fixed_size_list_field():
class TestModel(pydantic.BaseModel):
vec: Vector(16)
-9
View File
@@ -570,15 +570,6 @@ def test_query_builder(table):
assert all(np.array(rs[0]["vector"]) == [1, 2])
def test_query_multiple_vectors(table):
results = table.search([np.array([1, 2]), np.array([4, 5])]).limit(1).to_list()
assert len(results) == 2
results_by_query = {result["query_index"]: result for result in results}
assert results_by_query[0]["id"] == 1
assert results_by_query[1]["id"] == 2
def test_with_row_id(table: lancedb.table.Table):
rs = table.search().with_row_id(True).to_arrow()
assert "_rowid" in rs.column_names
+257 -88
View File
@@ -2,6 +2,7 @@
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
import asyncio
import ctypes
import gc
import os
@@ -9,7 +10,7 @@ import sys
import threading
import warnings
import weakref
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import CancelledError, ThreadPoolExecutor
from datetime import date, datetime, timedelta
from time import sleep
from typing import List
@@ -929,7 +930,6 @@ def test_polars(mem_db: DBConnection):
# enter table to polars dataframe
result = table.to_polars()
assert isinstance(result, pl.LazyFrame)
assert np.allclose(result.collect()["vector"].to_list(), data["vector"])
# make sure filtering isn't broken
@@ -1846,27 +1846,6 @@ def test_add_with_empty_fixed_size_list_drops_bad_rows(mem_db: DBConnection):
assert np.allclose(data["embedding"].to_pylist()[0], np.array([0.1] * 16))
def test_add_nullable_fixed_size_list_with_none(mem_db: DBConnection):
"""Regression test for issue #2340."""
table = mem_db.create_table(
"test_nullable_fixed_size_list",
schema=pa.schema(
[
pa.field("id", pa.string()),
pa.field("feature", pa.list_(pa.float32(), 256)),
pa.field("tags", pa.list_(pa.string())),
]
),
)
table.add([{"id": "1", "feature": None, "tags": ["tag1", "tag2"]}])
result = table.to_arrow()
assert result.to_pylist() == [
{"id": "1", "feature": None, "tags": ["tag1", "tag2"]}
]
def test_add_nullable_struct_with_none(mem_db: DBConnection):
"""Regression test for issue #2654: a nullable struct column whose
first batch contains only None values must not crash in
@@ -2167,6 +2146,260 @@ def test_restore(mem_db: DBConnection):
table.restore(0)
def test_restore_tracks_checkout_when_restore_fails():
class FailingRestore:
def __init__(self):
self.live_version = None
async def checkout(self, version):
self.live_version = version
async def restore(self):
raise RuntimeError("injected restore failure")
inner = FailingRestore()
table = LanceTable.__new__(LanceTable)
table._table = inner
table._checkout_version = None
with pytest.raises(RuntimeError, match="injected restore failure"):
table.restore(7)
assert table._checkout_version == inner.live_version
@pytest.mark.parametrize(
("operation", "expected_descriptor", "expected_restore_calls"),
[("checkout", 11, 0), ("restore", None, 1)],
)
def test_string_tag_resolves_before_checkout(
operation, expected_descriptor, expected_restore_calls
):
class Tags:
async def get_version(self, tag):
assert tag == "tag-v1"
return 11
class NoPostCheckoutVersionLookup:
def __init__(self):
self.tags = Tags()
self.checkout_versions = []
self.restore_calls = 0
async def checkout(self, version):
self.checkout_versions.append(version)
async def version(self):
raise RuntimeError("post-checkout version lookup must not run")
async def restore(self):
self.restore_calls += 1
inner = NoPostCheckoutVersionLookup()
table = LanceTable.__new__(LanceTable)
table._table = inner
table._checkout_version = 3
getattr(table, operation)("tag-v1")
assert inner.checkout_versions == [11]
assert table._checkout_version == expected_descriptor
assert inner.restore_calls == expected_restore_calls
@pytest.mark.parametrize("operation", ["checkout", "restore"])
def test_string_tag_resolution_failure_does_not_mutate_handle(operation):
class FailingTags:
async def get_version(self, tag):
assert tag == "missing-tag"
raise RuntimeError("injected tag lookup failure")
class UnchangedTable:
def __init__(self):
self.tags = FailingTags()
self.checkout_calls = 0
self.restore_calls = 0
async def checkout(self, version):
self.checkout_calls += 1
async def restore(self):
self.restore_calls += 1
inner = UnchangedTable()
table = LanceTable.__new__(LanceTable)
table._table = inner
table._checkout_version = 3
with pytest.raises(RuntimeError, match="injected tag lookup failure"):
getattr(table, operation)("missing-tag")
assert table._checkout_version == 3
assert inner.checkout_calls == 0
assert inner.restore_calls == 0
def test_native_state_transitions_are_serialized(monkeypatch):
from lancedb.background_loop import LOOP
class Inner:
def __init__(self):
self.live_version = None
async def checkout(self, version):
self.live_version = version
inner = Inner()
table = LanceTable.__new__(LanceTable)
table._table = inner
table._checkout_version = None
first_native_done = threading.Event()
release_first_call = threading.Event()
second_call_started = threading.Event()
second_call_done = threading.Event()
errors = []
original_run = LOOP.run
def delayed_delivery(awaitable):
result = original_run(awaitable)
if threading.current_thread().name == "checkout-1":
first_native_done.set()
assert release_first_call.wait(5)
return result
monkeypatch.setattr(LOOP, "run", delayed_delivery)
def checkout(version):
if version == 2:
second_call_started.set()
try:
table.checkout(version)
except BaseException as err:
errors.append(err)
finally:
if version == 2:
second_call_done.set()
first = threading.Thread(target=checkout, args=(1,), name="checkout-1")
first.start()
assert first_native_done.wait(5)
second = threading.Thread(target=checkout, args=(2,), name="checkout-2")
second.start()
assert second_call_started.wait(5)
assert not second_call_done.wait(0.1)
release_first_call.set()
first.join(5)
second.join(5)
assert not first.is_alive()
assert not second.is_alive()
assert errors == []
assert inner.live_version == 2
assert table._checkout_version == 2
@pytest.mark.parametrize(
("operation", "args", "initial_version", "expected_version"),
[
("checkout", (11,), 3, 11),
("checkout_latest", (), 3, None),
("restore", (), 11, None),
],
)
def test_native_state_commits_before_success_delivery(
monkeypatch, operation, args, initial_version, expected_version
):
from lancedb.background_loop import LOOP
class Inner:
def __init__(self, live_version):
self.live_version = live_version
async def checkout(self, version):
self.live_version = version
async def checkout_latest(self):
self.live_version = None
async def restore(self):
self.live_version = None
inner = Inner(initial_version)
table = LanceTable.__new__(LanceTable)
table._table = inner
table._checkout_version = initial_version
original_run = LOOP.run
def success_then_interrupt(awaitable):
original_run(awaitable)
raise KeyboardInterrupt("injected after native success")
monkeypatch.setattr(LOOP, "run", success_then_interrupt)
with pytest.raises(KeyboardInterrupt, match="injected after native success"):
getattr(table, operation)(*args)
assert inner.live_version == expected_version
assert table._checkout_version == expected_version
def test_native_state_waits_for_cancelled_delivery(monkeypatch):
from lancedb.background_loop import LOOP
class Inner:
def __init__(self):
self.live_version = 3
async def checkout(self, version):
await asyncio.sleep(0.01)
self.live_version = version
inner = Inner()
table = LanceTable.__new__(LanceTable)
table._table = inner
table._checkout_version = 3
original_run = LOOP.run
def cancel_while_running(awaitable):
async def cancel_after_start():
task = asyncio.create_task(awaitable)
await asyncio.sleep(0)
task.cancel()
return await task
return original_run(cancel_after_start())
monkeypatch.setattr(LOOP, "run", cancel_while_running)
with pytest.raises(CancelledError):
table.checkout(11)
assert inner.live_version == 11
assert table._checkout_version == 11
def test_reopen_preserves_explicit_table_location(tmp_path):
db = lancedb.connect(tmp_path / "db")
location = str(tmp_path / "physical-table")
table = LanceTable.create(
db,
"items",
pa.table({"x": [1]}),
location=location,
)
table._pid = -1
table._ensure_open()
assert table.count_rows() == 1
assert table._location == location
def test_restore_with_tags(mem_db: DBConnection):
table = mem_db.create_table(
"my_table",
@@ -2218,45 +2451,6 @@ def test_merge(tmp_db: DBConnection, tmp_path):
table.merge(other_dataset, left_on="id")
@pytest.mark.parametrize("storage_version", ["legacy", "stable"])
def test_search_after_merge(tmp_path, storage_version):
pytest.importorskip("lance")
pd = pytest.importorskip("pandas")
db = lancedb.connect(
tmp_path,
storage_options={"new_table_data_storage_version": storage_version},
)
rng = np.random.default_rng(42)
row_count = 512
vectors = rng.standard_normal((row_count, 8)).astype(np.float32)
table = db.create_table(
"search_after_merge",
data=pd.DataFrame(
{
"id": [str(i) for i in range(row_count)],
"vector": list(vectors),
}
),
)
table.create_index("vector", config=IvfPq(num_partitions=1, num_sub_vectors=2))
links = pd.DataFrame(
{
"id": [str(i) for i in range(row_count // 2)],
"link": [f"https://example.com/{i}" for i in range(row_count // 2)],
}
)
table.merge(links, left_on="id")
query = table.search(vectors[-1]).refine_factor(50).limit(10)
assert "ANN" in query.explain_plan(verbose=True)
result = query.to_arrow()
links_by_id = dict(zip(result["id"].to_pylist(), result["link"].to_pylist()))
assert links_by_id[str(row_count - 1)] is None
def test_delete(mem_db: DBConnection):
table = mem_db.create_table(
"my_table",
@@ -2799,40 +2993,15 @@ def test_create_with_embedding_function(mem_db: DBConnection):
assert actual == expected
def test_create_f16_table_from_arrow_data(mem_db: DBConnection):
dimension = 32
num_rows = 512
values = pa.array(
np.random.default_rng(42)
.standard_normal(num_rows * dimension)
.astype(np.float16)
)
df = pa.table(
{
"text": [f"s-{i}" for i in range(num_rows)],
"vector": pa.FixedSizeListArray.from_arrays(values, dimension),
}
)
table = mem_db.create_table("f16_tbl", data=df)
assert table.schema.field("vector").type == pa.list_(pa.float16(), dimension)
table.create_index(num_partitions=2, num_sub_vectors=2)
query = df["vector"][2].as_py()
expected = table.search(query).limit(2).to_arrow()
assert "s-2" in expected["text"].to_pylist()
def test_create_f16_table(mem_db: DBConnection):
class MyTable(LanceModel):
text: str
vector: Vector(32, value_type=pa.float16())
rng = np.random.default_rng(42)
df = pa.table(
{
"text": [f"s-{i}" for i in range(512)],
"vector": [rng.standard_normal(32).astype(np.float16) for _ in range(512)],
"vector": [np.random.randn(32).astype(np.float16) for _ in range(512)],
}
)
table = mem_db.create_table(
+76
View File
@@ -342,6 +342,42 @@ def _multiworker_dataloader_target(db_uri: str, result_queue):
result_queue.put(count)
class _LazyPermutationDataset(torch.utils.data.Dataset):
"""Match applications that create their Permutation inside a fork worker."""
def __init__(self, table):
self._table = table
self._permutation = None
self._length = table.count_rows()
def __len__(self):
return self._length
def __getitems__(self, indices):
if self._permutation is None:
inherited_connection = self._table._conn
self._permutation = Permutation.identity(self._table)
if self._table._conn is inherited_connection:
raise RuntimeError("Permutation reused a connection inherited by fork")
return self._permutation.__getitems__(indices)
def _lazy_multiworker_dataloader_target(db_uri: str, result_queue):
table = lancedb.connect(db_uri).open_table("test_table")
dataset = _LazyPermutationDataset(table)
dataloader = torch.utils.data.DataLoader(
dataset,
batch_size=10,
num_workers=2,
multiprocessing_context="fork",
)
count = 0
for batch in dataloader:
assert batch["a"].size(0) == 10
count += 1
result_queue.put(count)
def _remote_multiworker_dataloader_target(port: int, result_queue):
import lancedb
from lancedb.permutation import Permutation
@@ -410,6 +446,46 @@ def test_permutation_dataloader_fork_workers(tmp_path):
assert queue.get() == 100
@pytest.mark.skipif(
sys.platform != "linux",
reason=(
"fork() is unavailable on Windows and unsafe on macOS "
"(Apple frameworks/TLS are not fork-safe)"
),
)
def test_lazy_permutation_reopens_inherited_table_in_fork_worker(tmp_path):
"""A lazily built Permutation must not reuse an inherited table client.
Object-store table handles contain HTTP connection pools that are unsafe
after fork. The local table makes the handle replacement deterministic
without requiring an S3 service in the unit-test environment.
"""
db_uri = str(tmp_path / "db")
db = lancedb.connect(db_uri)
db.create_table("test_table", pa.table({"a": list(range(1000))}))
ctx = mp.get_context("spawn")
queue = ctx.Queue()
proc = ctx.Process(
target=_lazy_multiworker_dataloader_target,
args=(db_uri, queue),
)
proc.start()
proc.join(timeout=30)
if proc.is_alive():
proc.terminate()
proc.join(timeout=5)
if proc.is_alive():
proc.kill()
proc.join()
pytest.fail("Lazy Permutation hung in a fork-based DataLoader worker")
assert proc.exitcode == 0, f"child exited with code {proc.exitcode}"
assert not queue.empty(), "child produced no batches"
assert queue.get() == 100
@pytest.mark.skipif(
sys.platform != "linux",
reason=(
-15
View File
@@ -1,15 +0,0 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
from typing import assert_type
import lancedb
from lancedb import AsyncConnection, DBConnection
def check_connect_type() -> None:
assert_type(lancedb.connect("memory://"), DBConnection)
async def check_connect_async_type() -> None:
assert_type(await lancedb.connect_async("memory://"), AsyncConnection)
+1 -1
View File
@@ -1998,7 +1998,7 @@ requires-dist = [
{ name = "pillow", marker = "extra == 'clip'", specifier = ">=12.1.1" },
{ name = "pillow", marker = "extra == 'embeddings'", specifier = ">=12.1.1" },
{ name = "pillow", marker = "extra == 'siglip'", specifier = ">=12.1.1" },
{ name = "polars", marker = "extra == 'tests'", specifier = ">=0.19,<=1.32.3" },
{ name = "polars", marker = "extra == 'tests'", specifier = ">=0.19,<=1.3.0" },
{ name = "pre-commit", marker = "extra == 'dev'", specifier = ">=3.5.0" },
{ name = "pyarrow", specifier = ">=16" },
{ name = "pyarrow", marker = "extra == 'tests'", specifier = "<25" },
+2 -2
View File
@@ -49,8 +49,8 @@ lance-namespace = { workspace = true }
lance-namespace-impls = { workspace = true }
metrics = { workspace = true, optional = true }
metrics-util = { workspace = true, optional = true }
# Pin the GooseFS SDK to the version required by Lance's OpenDAL dependency.
goosefs-sdk = { version = "=0.1.9", optional = true }
# Pin the transitive GooseFS SDK until the 0.1.6 compile break is fixed upstream.
goosefs-sdk = { version = "=0.1.5", optional = true }
moka = { workspace = true }
pin-project = { workspace = true }
tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] }
+1 -1
View File
@@ -17,7 +17,7 @@ use arrow_array::builder::LargeBinaryBuilder;
use arrow_schema::{DataType, Field, Schema};
use lance::dataset::{BlobRangeRequest as LanceBlobRangeRequest, Dataset, WriteParams};
use lance_arrow::FieldExt;
use lance_file::version::LanceFileVersion;
use lance_encoding::version::LanceFileVersion;
use lance_io::object_store::ObjectStore;
use object_store::path::Path;
+1 -1
View File
@@ -34,7 +34,7 @@ use crate::remote::{
db::{OPT_REMOTE_API_KEY, OPT_REMOTE_HOST_OVERRIDE, OPT_REMOTE_REGION},
};
use lance::io::ObjectStoreParams;
pub use lance_file::version::LanceFileVersion;
pub use lance_encoding::version::LanceFileVersion;
#[cfg(feature = "remote")]
use lance_io::object_store::StorageOptions;
use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider};
+3 -105
View File
@@ -12,7 +12,7 @@ use lance::dataset::refs::Ref;
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_encoding::version::LanceFileVersion;
use lance_io::object_store::{StorageOptionsAccessor, StorageOptionsProvider};
use lance_table::io::commit::commit_handler_from_url;
use object_store::local::LocalFileSystem;
@@ -1294,11 +1294,9 @@ mod tests {
use crate::connection::ConnectRequest;
use crate::data::scannable::Scannable;
use crate::database::{CreateTableMode, CreateTableRequest};
use crate::query::QueryRequest;
use crate::table::{AnyQuery, WriteOptions};
use crate::table::WriteOptions;
use arrow_array::{Int32Array, RecordBatch, StringArray};
use arrow_schema::{DataType, Field, Schema};
use futures::TryStreamExt;
use std::path::PathBuf;
use tempfile::tempdir;
@@ -1440,94 +1438,6 @@ mod tests {
assert!(after_open.hits >= before_open.hits + 3);
}
/// Regression test for https://github.com/lancedb/lancedb/issues/3197.
#[cfg(unix)]
#[tokio::test]
async fn test_open_table_follows_hugging_face_symlinks() {
let (tempdir, db) = setup_database().await;
let schema = Arc::new(Schema::new(vec![Field::new("id", DataType::Int32, false)]));
db.create_table(CreateTableRequest {
name: "test".to_string(),
namespace_path: vec![],
data: Box::new(
RecordBatch::try_new(schema, vec![Arc::new(Int32Array::from(vec![1, 2, 3]))])
.unwrap(),
) as Box<dyn Scannable>,
mode: CreateTableMode::Create,
write_options: Default::default(),
location: None,
namespace_client: None,
})
.await
.unwrap();
let table_dir = tempdir.path().join("test.lance");
let versions_dir = table_dir.join("_versions");
let manifest_path = std::fs::read_dir(&versions_dir)
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| path.extension().is_some_and(|ext| ext == "manifest"))
.unwrap();
let data_path = std::fs::read_dir(table_dir.join("data"))
.unwrap()
.map(|entry| entry.unwrap().path())
.find(|path| path.extension().is_some_and(|ext| ext == "lance"))
.unwrap();
// Hugging Face snapshots keep dataset objects in a separate blob directory and
// expose them through relative symlinks.
let blobs_dir = tempdir.path().join("blobs");
std::fs::create_dir(&blobs_dir).unwrap();
let manifest_blob = "9b603c63d0e692e05d58be25605f2f2064cc781e5ff94fe983a405059547b816";
let data_blob = "be64f20e5723bd0a27cfdbdb41cf7d6fad94cd572a71973b717fb8340f4310c5";
std::fs::rename(&manifest_path, blobs_dir.join(manifest_blob)).unwrap();
std::fs::rename(&data_path, blobs_dir.join(data_blob)).unwrap();
std::os::unix::fs::symlink(Path::new("../../blobs").join(manifest_blob), &manifest_path)
.unwrap();
std::os::unix::fs::symlink(Path::new("../../blobs").join(data_blob), &data_path).unwrap();
let symlink_len = std::fs::symlink_metadata(&manifest_path).unwrap().len();
let target_len = std::fs::metadata(&manifest_path).unwrap().len();
assert_ne!(symlink_len, target_len);
drop(db);
let db = ListingDatabase::connect_with_options(&ConnectRequest {
uri: tempdir.path().to_str().unwrap().to_string(),
#[cfg(feature = "remote")]
client_config: Default::default(),
options: Default::default(),
namespace_client_properties: Default::default(),
manifest_enabled: false,
read_consistency_interval: None,
session: None,
})
.await
.unwrap();
let table = db
.open_table(OpenTableRequest {
name: "test".to_string(),
namespace_path: vec![],
index_cache_size: None,
lance_read_params: None,
location: None,
namespace_client: None,
managed_versioning: None,
})
.await
.unwrap();
let batches = table
.query(
&AnyQuery::Query(QueryRequest::default()),
Default::default(),
)
.await
.unwrap()
.try_collect::<Vec<_>>()
.await
.unwrap();
assert_eq!(batches.iter().map(RecordBatch::num_rows).sum::<usize>(), 3);
}
#[tokio::test]
async fn test_clone_table_basic() {
let (_tempdir, db) = setup_database().await;
@@ -2432,7 +2342,7 @@ mod tests {
#[tokio::test]
async fn test_table_uri() {
let (_tempdir, mut db) = setup_database().await;
let (_tempdir, db) = setup_database().await;
let mut pb = PathBuf::new();
pb.push(db.uri.clone());
@@ -2441,18 +2351,6 @@ mod tests {
let expected = pb.to_str().unwrap();
let uri = db.table_uri("test").ok().unwrap();
assert_eq!(uri, expected);
// URI paths always use forward slashes, even on Windows. Using
// `Path::join` here used to produce `az://container/prefix\\test.lance`,
// which Azure treated as a different object from the table returned by
// `table_names` (https://github.com/lancedb/lancedb/issues/1072).
for base_uri in ["az://container/prefix", "az://container/prefix/"] {
db.uri = base_uri.to_string();
assert_eq!(
db.table_uri("test").unwrap(),
"az://container/prefix/test.lance"
);
}
}
/// Regression: connecting via a URL-style URI (which goes through
+2 -2
View File
@@ -201,7 +201,7 @@ impl LanceNamespaceDatabase {
&self,
request: &DbCreateTableRequest,
) -> Result<(
Option<lance_file::version::LanceFileVersion>,
Option<lance_encoding::version::LanceFileVersion>,
Option<bool>,
Option<bool>,
)> {
@@ -214,7 +214,7 @@ impl LanceNamespaceDatabase {
let storage_version_override = storage_options
.and_then(|opts| opts.get(OPT_NEW_TABLE_STORAGE_VERSION))
.map(|s| s.parse::<lance_file::version::LanceFileVersion>())
.map(|s| s.parse::<lance_encoding::version::LanceFileVersion>())
.transpose()?;
let v2_manifest_override = storage_options
-70
View File
@@ -169,12 +169,6 @@ impl From<DataFusionError> for Error {
impl From<lance::Error> for Error {
fn from(source: lance::Error) -> Self {
if has_unsupported_local_filesystem_source(&source) {
return Self::NotSupported {
message: "the filesystem does not support an operation required for safe Lance commits (such as atomic rename). Object-storage mounts such as Mountpoint for Amazon S3 are not supported; use the native object-store URI (for example, s3://bucket/path) instead".to_string(),
};
}
// Try to unwrap external errors that were wrapped by lance
match source {
lance::Error::Wrapped { error, .. } => Self::from_box_error(error),
@@ -187,27 +181,6 @@ impl From<lance::Error> for Error {
}
}
fn has_unsupported_local_filesystem_source(error: &(dyn std::error::Error + 'static)) -> bool {
let mut current = Some(error);
let mut is_local_filesystem = false;
let mut is_unsupported = false;
while let Some(error) = current {
is_local_filesystem |= error
.downcast_ref::<object_store::Error>()
.is_some_and(|error| {
matches!(error, object_store::Error::Generic { store, .. } if *store == "LocalFileSystem")
});
is_unsupported |= error
.downcast_ref::<std::io::Error>()
.is_some_and(|error| error.kind() == std::io::ErrorKind::Unsupported);
if is_local_filesystem && is_unsupported {
return true;
}
current = error.source();
}
false
}
impl Error {
fn from_box_error(mut source: Box<dyn std::error::Error + Send + Sync>) -> Self {
source = match source.downcast::<Self>() {
@@ -297,46 +270,3 @@ impl From<candle_core::Error> for Error {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unsupported_filesystem_operations_have_actionable_error() {
let object_store_error = object_store::Error::Generic {
store: "LocalFileSystem",
source: Box::new(std::io::Error::from(std::io::ErrorKind::Unsupported)),
};
let lance_error = lance::Error::io_source(Box::new(object_store_error));
let error = Error::from(lance_error);
assert!(matches!(
error,
Error::NotSupported { message }
if message.contains("Mountpoint for Amazon S3")
&& message.contains("s3://bucket/path")
));
}
#[test]
fn other_io_errors_remain_lance_errors() {
let object_store_error = object_store::Error::Generic {
store: "LocalFileSystem",
source: Box::new(std::io::Error::from(std::io::ErrorKind::PermissionDenied)),
};
let lance_error = lance::Error::io_source(Box::new(object_store_error));
assert!(matches!(Error::from(lance_error), Error::Lance { .. }));
}
#[test]
fn unsupported_non_filesystem_errors_remain_lance_errors() {
let lance_error = lance::Error::io_source(Box::new(std::io::Error::from(
std::io::ErrorKind::Unsupported,
)));
assert!(matches!(Error::from(lance_error), Error::Lance { .. }));
}
}
+3 -5
View File
@@ -2942,7 +2942,7 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
}
#[derive(Serialize, Clone, Debug)]
pub struct MergeInsertRequest {
pub(crate) struct MergeInsertRequest {
on: String,
when_matched_update_all: bool,
when_matched_update_all_filt: Option<String>,
@@ -5907,18 +5907,16 @@ mod tests {
.await
.unwrap();
// Positions are relative to the first retained token, so dropping the
// leading "hello" stop word does not shift the remaining tokens.
assert_eq!(
tokens,
vec![
FtsToken {
text: "こんにちは".to_string(),
position: 0,
position: 1,
},
FtsToken {
text: "世界".to_string(),
position: 1,
position: 2,
},
]
);
+1 -1
View File
@@ -90,7 +90,7 @@ struct RemoteBlobState {
/// Seekable Cloud blob handle over HTTP Range.
#[derive(Debug)]
pub struct RemoteBlobFile {
pub(crate) struct RemoteBlobFile {
requester: Arc<dyn BlobRangeRequester>,
state: Mutex<RemoteBlobState>,
closed: AtomicBool,
+2 -2
View File
@@ -33,7 +33,7 @@ use crate::table::{AddResult, MergeResult};
/// same Arrow-IPC streaming body and error side-channel; only the target
/// endpoint, query parameters, and parsed result type differ.
#[derive(Debug, Clone)]
pub enum WriteOp {
pub(crate) enum WriteOp {
/// `add`: stream to `/v1/table/{id}/insert/`, optionally overwriting.
Insert { overwrite: bool },
/// `merge_insert`: stream to `/v1/table/{id}/merge_insert/` with the merge
@@ -49,7 +49,7 @@ pub enum WriteOp {
/// The parsed server response for a completed write, discriminated by the
/// operation that produced it.
#[derive(Debug, Clone)]
pub enum WriteResult {
pub(crate) enum WriteResult {
Add(AddResult),
Merge(MergeResult),
}
+1 -1
View File
@@ -10,7 +10,7 @@ use arrow_array::{
use arrow_schema::{DataType, Field, Fields, Schema};
use futures::TryStreamExt;
use lance::Dataset;
use lance_file::version::LanceFileVersion;
use lance_encoding::version::LanceFileVersion;
use lancedb::{
Connection, Error, Result, Table,
blob::{BlobRangeRequest, blob},