Compare commits

...
Author SHA1 Message Date
Jonathan M HsiehandClaude Opus 5 e9586850b6 fix(secrets): admit periods in Secret names and namespace segments
LanceDB namespace and table names are `[A-Za-z0-9_.-]`, so excluding periods
here put Secrets out of reach inside any namespace a user already has one in --
unreachable to create, alter, describe, drop or bind, with no way to recover
but renaming the namespace.

The rule is that set and nothing further. No positional constraint rides along:
the established grammar says nothing about which character comes first, so a
segment may begin with `_`, `-` or `.` today, and a Secret has to be nameable
wherever a namespace already is. A narrower rule would reintroduce the same
unaddressability it is here to remove, one character class over.

Names and path segments follow one rule, and the client matches the service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-11 15:32:05 +00:00
Jonathan M HsiehandClaude Opus 5 e541a3be93 fix(secrets): complete the binding and value contract at the Rust boundary
`validate` checked only the binding count, so a caller reaching the low-level
entry point past `bind_secrets` could register an environment variable name the
runtime cannot deliver, or bind a Secret to a name `runtime.env` already sets --
which would resolve by delivery order, with a value visible in the Function's
record and a value that is not.

Secret values are bounded here too, at the limit the service enforces, so an
oversized credential is refused before a request body is built rather than
after it has been serialized and uploaded.

Sandbox-reserved names are deliberately still the service's alone: that list
belongs to the runtime that owns it, and a copy here would drift from it
silently.

Both gate reproducers now fail closed with no request reaching the service.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-09 17:33:59 +00:00
Jonathan M HsiehandClaude Opus 5 b85f5f141f fix(secrets): keep credentials out of the client's own debug log
`log_request` logs any JSON body verbatim at debug, and Python and Node both
wire that logger to `LANCEDB_LOG`. `create_secret` posts the value in its body,
so ordinary SDK debug logging wrote the credential to application logs. The
comment on `write_secret` reasoned correctly about proxy traces and access logs
and missed the logger in this process.

Redaction cannot live in the value model: the logger sees the serialized body,
where the credential is already plaintext bytes. So bodies are suppressed for
the route instead, matched on the `secrets` path segment rather than a
versioned prefix, so a verb added under that namespace later is covered without
an edit here.

The same debug line prints the request's Debug, which prints headers, so the
API key was in every debug line of every request regardless of route. Marking
the header value sensitive is what stops that.

The end-to-end regression fails without this: the log carried
`,"value":"SECRET_VALUE_SENTINEL"}` verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-09 16:59:40 +00:00
Jonathan M HsiehandClaude Opus 5 4d09f8ce26 refactor(secrets): name the binding field for its delivery mode, cap in Rust
`secret_bindings` read as the general record of what a Function needs, but a
`Map<String, String>` keyed on environment-variable name can only ever hold an
env-delivered binding: an accessor binding has no variable to key on. Naming
the field for its delivery mode leaves a later mode a sibling field rather than
a tagged value type, which would break a field already in the identity hash.

The per-Function cap moves from `bind_secrets` to
`Connection::create_function_async`, above the backend dispatch, so it holds
for every language surface rather than only the one that validates first. The
low-level PyO3 entry point reached the wire past the Python check; it no longer
does.

Also corrects a doc comment claiming registration fails when a bound Secret is
absent. Existence is first answered at `add_columns`, by design.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfmeJ533rQDnPBkMtjerV6
2026-09-09 16:59:40 +00:00
Jonathan M HsiehandClaude Opus 5 7b29fb2f51 feat(secrets): named Secrets and EnvVarSecret bindings
Adds the client half of database-scoped named Secrets: a Secret is a name and
an opaque value stored by the service, and a Function binds one to the
environment variable its library already reads.

- `db.create_secret` / `alter_secret` / `list_secrets` / `describe_secret` /
  `drop_secret` on sync, async and remote connections, with the pyo3 binding
  and the Rust client behind them. There is no read API by construction rather
  than by policy: no code path returns a stored credential, and
  `describe_secret` answers with metadata only.
- `EnvVarSecret(secret=..., env_variable=...)` pairs a Secret with the variable
  it arrives in. It is a pure local constructor -- it contacts no server, so it
  cannot fail on a Secret that does not exist -- and it exists so a bare string
  in that position, which would be a credential, is a TypeError rather than a
  plausible-looking mistake that reads identically in a diff.
- `create_function(..., secrets=[...])` carries the bindings as
  `secret_bindings`, a map from variable name to Secret name. The value never
  travels: it is resolved by the service when the Function runs, which is what
  lets a rotation reach columns pinned to an older FunctionVersion.

The UDF body is unchanged and stays portable -- it reads `OPENAI_API_KEY` the
way it always did, and the binding is what puts a value there.

Squashed: the original three commits were a first design plus a rewrite of it,
so their sequence describes an interface that no longer exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XE1UwYKsgbb3USBfkqCE6v
2026-09-09 00:12:57 +00:00
Lance Release 0111a72dc3 Bump version: 0.39.0-beta.3 → 0.39.0-beta.4 2026-09-06 20:46:55 +00:00
LanceDB RobotandJack Ye a487d4033e chore: update lance dependency to v12.0.0-beta.14 (#4141)
Update the Rust workspace Lance dependencies and Java lance-core from
v12.0.0-beta.11 to
[v12.0.0-beta.14](https://github.com/lance-format/lance/releases/tag/v12.0.0-beta.14),
refreshing Cargo.lock. Resolve two Clippy diagnostics by making an
internal Node.js helper private and using a byte string literal in a
remote-table test fixture.

Validation: `cargo clippy --quiet --workspace --tests --all-features --
-D warnings`, `cargo fmt --all --quiet`, `git diff --check`, and `pnpm
build` in nodejs.

---------

Co-authored-by: Jack Ye <yezhaoqin@gmail.com>
2026-09-06 13:43:35 -07:00
dependabot[bot] 02ea0dda9f build(deps-dev): bump the nodejs-deps group across 1 directory with 2 updates (#4134)
Bumps the nodejs-deps group with 2 updates in the /nodejs directory:
[@opentelemetry/sdk-metrics](https://github.com/open-telemetry/opentelemetry-js)
and [ts-jest](https://github.com/kulshekhar/ts-jest).

Updates `@opentelemetry/sdk-metrics` from 2.10.0 to 2.11.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-js/releases">@​opentelemetry/sdk-metrics's
releases</a>.</em></p>
<blockquote>
<h2>v2.11.0</h2>
<h2>2.11.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>feat(context-async-hooks): implement <code>attach()</code> on
<code>AsyncLocalStorageContextManager</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6845">#6845</a>
<a href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a>
<ul>
<li>On Node.js 25.9+, delegates to
<code>AsyncLocalStorage.withScope()</code> returning a native
<code>RunScope</code>. On older Node.js, falls back to
<code>enterWith()</code> with a manual disposable wrapper.</li>
</ul>
</li>
<li>feat(sdk-trace): allow configuring the force flush timeout per call
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/6929">#6929</a>
<a
href="https://github.com/LarryHu0217"><code>@​LarryHu0217</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>fix(sdk-metrics): ignore <code>Infinity</code> in exponential
histograms <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7015">#7015</a>
<a href="https://github.com/mwear"><code>@​mwear</code></a></li>
</ul>
<h3>🏠 Internal</h3>
<ul>
<li>perf(sdk-metrics): reuse a single DataView for exponential histogram
bit reads <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6998">#6998</a>
<a href="https://github.com/mwear"><code>@​mwear</code></a></li>
<li>chore(ci): run documentation tests on a weekly schedule <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6920">#6920</a>
<a
href="https://github.com/LarryHu0217"><code>@​LarryHu0217</code></a></li>
<li>feat(ci): support pre-releases and major version bumps in the
release workflow <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/6768">#6768</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
<li>chore(resources): Ensure that multiple uses of
serviceInstanceIdDetector.detect() return the <em>same</em> value for
<code>service.instance.id</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/open-telemetry/opentelemetry-js/blob/main/CHANGELOG.md">@​opentelemetry/sdk-metrics's
changelog</a>.</em></p>
<blockquote>
<h2>2.11.0</h2>
<h3>🚀 Features</h3>
<ul>
<li>feat(context-async-hooks): implement <code>attach()</code> on
<code>AsyncLocalStorageContextManager</code> <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6845">#6845</a>
<a href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a>
<ul>
<li>On Node.js 25.9+, delegates to
<code>AsyncLocalStorage.withScope()</code> returning a native
<code>RunScope</code>. On older Node.js, falls back to
<code>enterWith()</code> with a manual disposable wrapper.</li>
</ul>
</li>
<li>feat(sdk-trace): allow configuring the force flush timeout per call
<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/6929">#6929</a>
<a
href="https://github.com/LarryHu0217"><code>@​LarryHu0217</code></a></li>
</ul>
<h3>🐛 Bug Fixes</h3>
<ul>
<li>fix(sdk-trace-base): avoid a Webpack self-reference error in
CommonJS output <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/6981">#6981</a>
<a href="https://github.com/sansynx"><code>@​sansynx</code></a></li>
<li>fix(sdk-metrics): ignore <code>Infinity</code> in exponential
histograms <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/7015">#7015</a>
<a href="https://github.com/mwear"><code>@​mwear</code></a></li>
</ul>
<h3>🏠 Internal</h3>
<ul>
<li>perf(sdk-metrics): reuse a single DataView for exponential histogram
bit reads <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6998">#6998</a>
<a href="https://github.com/mwear"><code>@​mwear</code></a></li>
<li>chore(ci): run documentation tests on a weekly schedule <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/pull/6920">#6920</a>
<a
href="https://github.com/LarryHu0217"><code>@​LarryHu0217</code></a></li>
<li>feat(ci): support pre-releases and major version bumps in the
release workflow <a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/6768">#6768</a>
<a
href="https://github.com/pichlermarc"><code>@​pichlermarc</code></a></li>
<li>chore(resources): Ensure that multiple uses of
serviceInstanceIdDetector.detect() return the <em>same</em> value for
<code>service.instance.id</code></li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/0b72a81636fa476e8f1f1afd2ae0c90a1362194c"><code>0b72a81</code></a>
chore: prepare next release (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7044">#7044</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/a9c5338a9f485f1433df9308f24bd7397a4a0321"><code>a9c5338</code></a>
ci: roll prerelease changelog into one final release changelog (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7045">#7045</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/f41805e769ba10fb6dae72a4b7a5a3dc67cca82e"><code>f41805e</code></a>
chore: prepare next release (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7042">#7042</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/b85eb28343ff6234e2bb4d46b7b4a3d360e5ea2f"><code>b85eb28</code></a>
chore(instrumentation-http): fix lint errors (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7039">#7039</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/3f9253009be2ae48419432e79a597ead7be8be6a"><code>3f92530</code></a>
ci: support pre-releases and major version bumps in release workflow (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7035">#7035</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/82a58316a63b6fde62afd268e145b82222d328cf"><code>82a5831</code></a>
docs(otlp-exporter-base): document HTTP exporter options (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/6735">#6735</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/e086dec7f9304107ef6d50b5877be88895c06aa7"><code>e086dec</code></a>
Merge commit from fork</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/59dac70d00d46fa56b2b921cf721fd922730f23d"><code>59dac70</code></a>
chore(deps): update jamesives/github-pages-deploy-action action to
v4.9.0 (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7">#7</a>...</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/d0ce7532b058631ec9eec111c04fefe7fd873e1f"><code>d0ce753</code></a>
chore: add <a
href="https://github.com/maryliag"><code>@​maryliag</code></a> to
maintainers (<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7024">#7024</a>)</li>
<li><a
href="https://github.com/open-telemetry/opentelemetry-js/commit/03469a129f97265c8eec93d566e9e00d4f741db3"><code>03469a1</code></a>
chore(deps): update open-telemetry/shared-workflows action to v0.10.0
(<a
href="https://redirect.github.com/open-telemetry/opentelemetry-js/issues/7032">#7032</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/open-telemetry/opentelemetry-js/compare/v2.10.0...v2.11.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `ts-jest` from 29.4.9 to 29.4.12
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/kulshekhar/ts-jest/releases">ts-jest's
releases</a>.</em></p>
<blockquote>
<h2>v29.4.12</h2>
<p>Please refer to <a
href="https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v29.4.11</h2>
<p>Please refer to <a
href="https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
<h2>v29.4.10</h2>
<p>Please refer to <a
href="https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md">CHANGELOG.md</a>
for details.</p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/kulshekhar/ts-jest/blob/main/CHANGELOG.md">ts-jest's
changelog</a>.</em></p>
<blockquote>
<h2><a
href="https://github.com/kulshekhar/ts-jest/compare/v29.4.11...v29.4.12">29.4.12</a>
(2026-07-22)</h2>
<h3>Features</h3>
<ul>
<li><strong>compiler:</strong> support TypeScript 7 projects through
compatibility aliases (<a
href="https://redirect.github.com/kulshekhar/ts-jest/pull/5386">#5386</a>)</li>
</ul>
<h2><a
href="https://github.com/kulshekhar/ts-jest/compare/v29.4.10...v29.4.11">29.4.11</a>
(2026-05-21)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>preserve Bundler on the CJS path under TypeScript &gt;= 6 (<a
href="https://github.com/kulshekhar/ts-jest/commit/39418187515f11b6584d35a4e3ddf50231f74936">3941818</a>),
closes <a
href="https://redirect.github.com/kulshekhar/ts-jest/issues/4198">#4198</a></li>
</ul>
<h2><a
href="https://github.com/kulshekhar/ts-jest/compare/v29.4.9...v29.4.10">29.4.10</a>
(2026-05-18)</h2>
<h3>Bug Fixes</h3>
<ul>
<li>pass <code>resolutionMode</code> to
<code>ts.resolveModuleName</code> for hybrid module support (<a
href="https://github.com/kulshekhar/ts-jest/commit/b557a85f85c3fd34523ec3a15293afbdc9dea83c">b557a85</a>)</li>
<li>rebuild <code>Program</code> when consecutive compiles need
different module kinds (<a
href="https://github.com/kulshekhar/ts-jest/commit/a82a2b32c4987a5249fd5284283117dd2fa3be47">a82a2b3</a>),
closes <a
href="https://redirect.github.com/kulshekhar/ts-jest/issues/4774">#4774</a></li>
<li>respect tsconfig <code>moduleResolution</code> instead of forcing
<code>Node10</code> (<a
href="https://github.com/kulshekhar/ts-jest/commit/1bffffc667557c173ae0c1f93dd436920775dac4">1bffffc</a>)</li>
<li><strong>transformer:</strong> transpile <code>mjs</code> files from
<code>node_modules</code> for CJS mode (<a
href="https://github.com/kulshekhar/ts-jest/commit/96d025dd912ea2bceb18b67d2d509ada7a756d9d">96d025d</a>)</li>
<li><strong>transformer:</strong> use a consistent comparator in
hoist-jest sortStatements (<a
href="https://github.com/kulshekhar/ts-jest/commit/8a8fd2fb8446655bba18367db9306a1089490e62">8a8fd2f</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/3f05625da10da954fdf0a10394385008275ddbb3"><code>3f05625</code></a>
chore(release): 29.4.12</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/df28b27f2e60edf275866763a3cdf745360d3eae"><code>df28b27</code></a>
docs: clarify TypeScript version prerequisites</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/c8a614af419b1dcc90f3d1a7a48238ac1b637e6b"><code>c8a614a</code></a>
docs: mention TypeScript 7 setup in README</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/06c79d4cebfa785749b2f96ef2dbeffc12798c47"><code>06c79d4</code></a>
fix: address TypeScript 7 review feedback</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/f10746008e512203ad7d8c581e2c58cc7dcd43c8"><code>f107460</code></a>
docs: explain TypeScript 7 compatibility setup</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/33882274c1e4fc3a06cece3b21f4873182c8fee7"><code>3388227</code></a>
test(e2e): add TypeScript compatibility matrix</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/891dc731ff9f31785ad9698e4d7cfa6078991fe6"><code>891dc73</code></a>
fix(compiler): support TypeScript 7 compatibility aliases</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/eb135ebe819991b1e10c998915cc6db2057c4de1"><code>eb135eb</code></a>
build(deps-dev): bump shell-quote from 1.8.4 to 1.10.0 in /examples</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/d5d80a34bc575be130db2b9f5cd0958173982cda"><code>d5d80a3</code></a>
ci: pin google osv scan action at v2.3.5</li>
<li><a
href="https://github.com/kulshekhar/ts-jest/commit/6bf293f0a4ddf468735d81fcdf04f923278e030c"><code>6bf293f</code></a>
build(deps): bump shell-quote from 1.8.4 to 1.10.0 in /website</li>
<li>Additional commits viewable in <a
href="https://github.com/kulshekhar/ts-jest/compare/v29.4.9...v29.4.12">compare
view</a></li>
</ul>
</details>
<br />


Dependabot will resolve any conflicts with this PR as long as you don't
alter it yourself. You can also trigger a rebase manually by commenting
`@dependabot rebase`.

[//]: # (dependabot-automerge-start)
[//]: # (dependabot-automerge-end)

---

<details>
<summary>Dependabot commands and options</summary>
<br />

You can trigger Dependabot actions by commenting on this PR:
- `@dependabot rebase` will rebase this PR
- `@dependabot recreate` will recreate this PR, overwriting any edits
that have been made to it
- `@dependabot show <dependency name> ignore conditions` will show all
of the ignore conditions of the specified dependency
- `@dependabot ignore <dependency name> major version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's major version (unless you unignore this specific
dependency's major version or upgrade to it yourself)
- `@dependabot ignore <dependency name> minor version` will close this
group update PR and stop Dependabot creating any more for the specific
dependency's minor version (unless you unignore this specific
dependency's minor version or upgrade to it yourself)
- `@dependabot ignore <dependency name>` will close this group update PR
and stop Dependabot creating any more for the specific dependency
(unless you unignore this specific dependency or upgrade to it yourself)
- `@dependabot unignore <dependency name>` will remove all of the ignore
conditions of the specified dependency
- `@dependabot unignore <dependency name> <ignore condition>` will
remove the ignore condition of the specified dependency and ignore
conditions


</details>

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-06 09:41:24 -07:00
Lance Release c7980dbc40 Bump version: 0.39.0-beta.2 → 0.39.0-beta.3 2026-09-06 08:59:56 +00:00
Jack Ye 1b0f9329ea fix: compare Function output list children by type only (#4137)
A table whose Function output type contains a list cannot be appended
to. That is every embedding column.

`add()` re-validates the table's own schema against its bindings before
it looks at the incoming data, so the failure does not depend on what
you are writing:

```
ValueError: Invalid input, Function output 'udf_text_embedding_384'
            type no longer matches binding 'fb_...'
```

The check required a list child to be identical to the declaration.
Lance rewrites a list item's name and nullability when it writes, so a
declared `fixed_size_list<item: float not null>` is stored as
`fixed_size_list<item: float>` and never matches again.

The server already draws this distinction —
`job_executor::function_arrow_type::equivalent` compares list children
by type and struct children by identity — which is why declaring the
column succeeded in the first place. This brings the client's copy of
the check into line so the two agree on what a valid Function column
looks like.

Struct children still compare by name and nullability, and the list
length is still part of the declaration.
2026-09-06 01:58:40 -07:00
Lance Release e5cc7a4d66 Bump version: 0.39.0-beta.1 → 0.39.0-beta.2 2026-09-05 23:26:00 +00:00
Jack Ye 21f11b4463 feat!: replace get_job/job_history with describe_job/query_job_events (#4130)
A 1M-row column refresh over 200 fragments produced no visible result,
and the client could only ever say `"running"`. Everything needed to
diagnose it already existed server-side — the job registry records a
`claim`/`claim_complete` pair per fragment carrying `rows_processed` —
but none of it was reachable.

## Before

Four ways to ask about a job, none of which told you much.

```python
job = table.refresh_column_async("embedding")
job.status()               # "running". That was the entire debug surface.
db.get_job(job_id)         # state, and a spec. No result, no progress.
db.job_history(job_id)     # raw record batches, no limit, no filter
db.job(job_id)             # a handle that knew nothing
```

## After

Open a job the way you open a table; the handle answers everything.

```python
job = db.open_job(job_id)      # raises JobNotFoundError if there is no such job
```

```python
>>> print(job)
Job(
    id='job-1',
    state='failed',
    job_type='refresh_column',
    creation_ms=1757000000000,
    spec={
        "column": "embedding",
        "num_workers": 4
    },
    failure=JobFailureInfo(phase='execute', message='worker died', retryable=True),
)
```

Individual fields are there too — `job.state`, `job.job_type`,
`job.creation_ms`, `job.spec`, `job.result`, `job.failure` — and
`job.result` carries `rows_assigned` / `rows_failed` as soon as the job
succeeds, with no `wait()` required.

Per-fragment progress *while it is still running*:

```python
done = job.events(filter="state = 'claim_complete'", limit=10_000)
done.column("rows_processed").to_pylist()      # [5000, 5000, ...]
```

The handle an async action returns is the same object, one `refresh()`
away:

```python
job = table.refresh_column_async("embedding")
job.refresh()
job.state, job.result
```

TypeScript is the same experience, down to `console.log`:

```ts
const job = await db.openJob(jobId);   // rejects if there is no such job
console.log(job);                      // same multi-line layout
job.state; job.jobType; job.spec; job.result; job.failure;
const done = await job.events({ filter: "state = 'claim_complete'", limit: 10_000 });
```

## Why each piece matters

- **A result without waiting.** `rows_assigned` / `rows_failed` used to
live only on the terminal result, so a job that never terminated
reported nothing at all.
- **`limit`.** The server caps event rows at 1000 and truncates without
saying so, which silently hid most of a 200-fragment job's history.
- **`filter`.** `claim_complete` rows carry per-claim `rows_processed` —
the only progress signal that exists mid-flight.
- **Events outlive the worker.** They live in the job registry, not in
pod logs that vanish with the pod.
- **One place to ask.** `open_job` replaces `describe_job`,
`query_job_events` and `job`, so a question about a job has one answer
instead of one per calling location.
- **A missing job is an error, not a `None`.** The common case is a job
id copied out of a log, where absence is the surprise worth raising —
and it matches `open_table`.
- **Printing is the debug surface.** Every field on its own line, JSON
payloads keeping their structure. An unrefreshed handle stays on one
line, because there is nothing to lay out.
- **In-process jobs say so.** A local refresh reports `state` and leaves
the rest null rather than inventing fields it has no record for.

`list_jobs` and `cancel_job` stay as they were: one lists, the other is
a one-shot action that should not need a describe first.

## Breaking

All shipped in 0.38.0. No deprecated aliases.

| Was | Now |
| --- | --- |
| `Connection.get_job` → `describe_job` | `Connection.open_job` returns
a populated `Job`, or raises |
| `Connection.job_history` → `query_job_events` | `job.events(...)` |
| `Connection.job` | `Connection.open_job` |
| Python events → `List[pa.RecordBatch]` | `pa.Table` |
| `JobDescription.spec_json` / `.result_json` | internal; use `job.spec`
/ `job.result` |

Node's `Job` is now a TypeScript class wrapping the native handle, so it
returns an Arrow table and parsed values like Python does. New
`Error::JobNotFound` / `JobNotFoundError`; the three job exceptions are
now in the Python API reference.
2026-09-05 16:24:23 -07:00
Drew 8c9c5c5a5f fix: stop enabling stable row ids on blob table create (#4126)
This PR stops blob table create from implicitly enabling stable row ids.
A blob schema still selects Lance file format 2.2, but row id behavior
stays with the table config.

Compact then fetch with a `_rowid` captured before compaction is still
not supported on a default table. That needs `take` to remap row
addresses through blob reuse rather than making stable row ids a
blob-table default.

BREAKING CHANGE: blob create no longer enables stable row ids. A blob
schemastill selects Lance file format 2.2. Fetch uses `_rowid` on HEAD.
Held ids survive compact only when the table has stable row ids.


## Testing

* `cargo fmt --all`
* `ruff format .`
* `ruff check .`
* `cargo clippy --quiet --features remote --tests --examples -p lancedb`
* `cargo test --quiet --features remote -p lancedb --test
blob_integration`
* `python/.venv/bin/pytest python/python/tests/test_blob.py -q`
2026-09-04 14:52:15 +08:00
Jack Ye aab23eb39e feat: support nullable named function outputs (#4123)
Supports fully nullable named Function outputs while preserving the
distinction between a valid all-null struct and a null/unassigned
result.

## Concrete example

This UDF contract is now valid:

```python
@udf(
    input_schema=pa.schema([
        pa.field("text", pa.string(), nullable=False),
    ]),
    output_schema=pa.schema([
        pa.field(
            "embedding",
            pa.list_(pa.float32(), list_size=1024),
            nullable=True,
        ),
        pa.field("embedding_failure_reason", pa.string(), nullable=True),
        pa.field("embedding_failure_code", pa.int32(), nullable=True),
    ]),
)
def embed(text):
    ...
```

A successful row can return:

```text
embedding = [0.12, ...]
embedding_failure_reason = NULL
embedding_failure_code = NULL
```

If remote inference still fails after retries, it can return:

```text
embedding = NULL
embedding_failure_reason = "HTTP 429: rate limited"
embedding_failure_code = 429
```

An all-null but valid result struct is also assigned; it is not mistaken
for unfinished work.

## Binding shapes

- Mapping the result to one output column stores the `StructArray`
directly, including its parent validity bitmap.
- Flattening the result into top-level columns stores the parent
validity in a reserved internal nullable Boolean assignment column that
is not part of the UDF result mapping.
- An outer null struct remains unassigned/skipped. A valid struct
remains assigned regardless of which child fields are null.
- Scalar Function outputs remain non-nullable.

The contract is preserved through Python registration, Rust application
planning, persisted `FunctionBinding` metadata, schema revalidation, and
Enterprise execution.
2026-09-03 22:23:53 -07:00
Jack Ye e639b1b650 feat: add asynchronous remote SQL queries (#4070)
## Summary

Add SQL execution to remote LanceDB connections. On the standard
synchronous connection, `execute_query` waits for the initial result
stream and returns its Arrow reader. `execute_query_async` is called
without Python `await` and immediately returns a query handle for status
inspection, streaming, or cancellation. Local databases report that SQL
is not supported.

The transport and query lifecycle live in Rust. Python exposes
native-backed synchronous and asynchronous connection methods and query
wrappers; it does not use PyArrow's Flight client.

## User experience

The standard synchronous connection supports both direct reads and
background query execution:

```python
db = lancedb.connect(
    "db://analytics",
    api_key="ldb_...",
    sql_host_override="grpc+tls://sql.example.com:10026",
)

# Direct execution waits only until the initial result stream is available.
# Later batches continue streaming as the query progresses.
reader = db.execute_query(
    "SELECT * FROM events",
    default_namespace_path=["production"],
)
for batch in reader:
    print(batch.num_rows)

# Background execution returns a query handle immediately. Despite the
# `_async` suffix, no Python `await` is needed on a synchronous connection.
query = db.execute_query_async("SELECT * FROM events")
print(query.id)

description = db.describe_query(query.id)
print(description.status)
print(description.progress)
print(description.expires_at)

# Start reading as soon as the service advertises partial results. The reader
# continues polling and yields newly available record batches until the query
# and all result endpoints are complete.
reader = query.reader()
for batch in reader:
    print(batch.num_rows)

# Or cancel a different still-running query. Its status becomes "cancelling"
# while the server is still working, then "cancelled" once confirmed.
cancelled_query = db.execute_query_async("SELECT * FROM large_events")
cancelled_query.cancel()
```

The less commonly used asynchronous connection exposes the same
operations as coroutines:

```python
async_db = await lancedb.connect_async(
    "db://analytics",
    api_key="ldb_...",
    sql_host_override="grpc+tls://sql.example.com:10026",
)
query = await async_db.execute_query_async("SELECT * FROM events")
async for batch in await query.reader():
    print(batch.num_rows)
```

The UUIDv7 query id is scoped to the connection that submitted it. The
connection retains lightweight shared query state used by
`query.describe()` and `db.describe_query(query.id)`; the id does not
encode SQL or a Flight continuation token and is not a cross-connection
resume token. Abandoned state has bounded retention, and terminal state
remains available briefly.

Unqualified table names use the connected database and the `public`
namespace by default. `default_namespace_path` accepts a list such as
`["production", "events"]`. SQL can still use qualified names to
reference other databases and namespaces available to the deployment.

## Design

- Uses Arrow Flight `PollFlightInfo` for submission and long polling,
`DoGet` for results, and `CancelFlightInfo` for cancellation. Each
`PollInfo.info` is treated as the cumulative set of currently available
endpoints, so advertised tickets are consumed once and batches can be
delivered before execution is complete.
- Serializes result completion and cancellation into one lifecycle. A
server-accepted request reports `cancelling` and wakes blocked
status/result work; a later retry can confirm `cancelled`. Result
retrieval is rejected after cancellation is accepted, while cancellation
after a result was already delivered is a no-op.
- Assigns a time-ordered UUIDv7 connection-scoped query id and retains
only shared evolving lifecycle state, keeping SQL, Flight continuation
tokens, and Arrow result data out of public ids and the registry.
- Leaves admission control to the server while honoring server
expiration and a local fallback retention window for abandoned entries.
- Retains terminal ids for five minutes so they remain available for
connection-level description.
- Keeps one lazily initialized SQL client on each remote database
connection and attaches fresh authentication, routing, namespace, and
request metadata to every operation.
- Applies the configured overall timeout to each execution, description,
reader, and cancellation operation. A result reader carries one absolute
deadline from `reader()` through the end of streaming; connect and read
timeouts continue to bound their individual phases.
- Returns a bounded, backpressured, single-consumer Arrow stream rather
than collecting the full result in memory. Dropping the reader stops
downloading but does not implicitly cancel the server query.
- Preserves typed schemas for empty result sets through the stream
schema.
- Accepts Flight result messages up to 1 GiB so a valid row containing a
large blob, string, or vector is not rejected by tonic's 4 MiB default
receive limit.
- Supports the Python client first while keeping the authoritative
implementation in the Rust core.
2026-09-03 14:59:14 -07:00
2779b75d0d fix(node): resolve remaining pnpm audit findings (#4073)
`pnpm audit` in `nodejs/` reported a number of vulnerable transitive
dependencies. Most were resolved by `pnpm audit --fix`, which bumped the
affected packages in the lockfile; the `minimumReleaseAgeExclude`
additions in `pnpm-workspace.yaml` are its bookkeeping, exempting the
specific patched versions from the repository's 24-hour hold on newly
published packages. Two findings needed handling by hand, because the
vulnerable package could not simply be moved to a newer release in
place.

`@opentelemetry/sdk-metrics` 1.30.1 pins `@opentelemetry/core` to its
own exact version, and the 1.x line is end-of-life, so
GHSA-8988-4f7v-96qf (unbounded memory allocation in W3C Baggage
propagation) has no fix available on 1.x. This PR moves the dependency
to 2.x, which brings in a patched `@opentelemetry/core`. It is a
dev-only dependency with a single consumer, `__test__/otel.test.ts`, and
the parts of the API that test uses are unchanged between 1.x and 2.x.

`@huggingface/transformers` pins `sharp: ^0.33.5`, and no released
version of transformers has moved past `^0.34.5` — every version in
those ranges inherits the libvips CVEs in GHSA-f88m-g3jw-g9cj, so there
is no upstream release to upgrade to. This PR adds a pnpm `overrides`
entry pinning sharp to the patched `^0.35.4` line instead.

`pnpm audit` now reports no known vulnerabilities.

## Not included

The sharp override only applies to this repository's own dependency
tree, since pnpm overrides are not published to npm. Anyone installing
`@lancedb/lancedb` together with the optional
`@huggingface/transformers` still resolves sharp 0.33.5, and will until
transformers itself moves to sharp 0.35. Practical exposure there is
low: the CVEs require decoding untrusted images, and LanceDB's
transformers embedding function is text-only.

`nodejs/examples/` is a separate install with its own lockfile and is
untouched here. It pins `sharp: "0.33.5"` directly and `pnpm audit`
reports 19 findings against it. Bumping sharp there is more involved
than it looks, because sharp 0.35 requires Node >= 20.9 while the
examples tests run on the Node 18/20 CI matrix, so it is left for
separate work.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-09-03 07:31:03 +08:00
lancedb-gatefixer[bot] 0d19a6c546 fix: support nested-list FTS indexing (#4059)
## Summary

- validate native FTS fields against the recursively resolved terminal
text leaf
- preserve canonical public paths and list depth for Lance
document-boundary handling
- cover async nested-list index creation and deepest-list `_doc_index`
search coordinates

## Root cause

The FTS resolver recursively found the terminal text field but returned
the outer list field. Native validation therefore rejected
`List(List(Utf8))` before Lance could create a list-element index.

## Validation

- `cargo test --quiet --features remote -p lancedb
test_nested_list_fts_uses_deepest_document_coordinates -- --nocapture`
- `cargo test --quiet --features remote -p lancedb
test_execute_async_validates_fts_input_before_starting_job`
- `cargo test --quiet --features remote -p lancedb
test_public_fts_field_path_prefers_exact_case`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`
- `cargo fmt --all -- --check`

Fixes #4058

<!-- lance-gatekeeper-fix:v1 agent=a4e48f49d12b2b6de7f10adb1c5bf5f5
generation=1 -->

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-09-02 17:51:21 +08:00
Lance Release c0f33f8627 Bump version: 0.39.0-beta.0 → 0.39.0-beta.1 2026-09-02 05:28:44 +00:00
LanceDB Robot 904bd975e5 chore: update lance dependency to v12.0.0-beta.11 (#4118)
Updates the Rust workspace Lance dependencies and Java lance-core
dependency to v12.0.0-beta.11.

No compatibility fixes were required. Trigger:
https://github.com/lance-format/lance/releases/tag/v12.0.0-beta.11
2026-09-01 22:27:12 -07:00
Will JonesandClaude Opus 5 d2ca0ce0ab feat: accept multiple on columns for merge insert on remote tables (#4102)
Merge insert has always taken a list of columns to match on, and local
tables have always joined on all of them. Remote tables did not: any
list longer than one was rejected with `MergeInsertBuilder only supports
a single 'on' column`, so a composite-key upsert was impossible against
LanceDB Cloud and Enterprise from Rust, Python or TypeScript.

The remote request now carries `on` as a list and sends it as one
repeated query parameter per column — `?on=shard_key&on=id`. That is how
the lance-namespace spec encodes an array-valued `on`, so the server
receives a composite key in the shape it expects. A single column still
serializes to `?on=id`, exactly what clients sent before, so existing
callers are unaffected. A column repeated within `on` is now rejected
client-side rather than sent for the server to reject with a 400.

No binding changes were needed: `Table.merge_insert` in Python and
`Table.mergeInsert` in TypeScript already accepted a list, it just could
not reach a remote table. Both gain a test for composite keys, and the
doc comments now say what passing several columns means.

Part of
[ENT-2084](https://linear.app/lancedb/issue/ENT-2084/mergeinsertintotablerequest-support-multiple-columns-for-the).

## Example

```python
table.merge_insert(["shard_key", "id"]) \
    .when_matched_update_all() \
    .when_not_matched_insert_all() \
    .execute(new_data)
```

A row whose `id` matches an existing row but whose `shard_key` differs
is an insert, not an update.

## Not included

Java. Java callers reach merge insert through
`org.lance.namespace.LanceNamespace`, whose
`MergeInsertIntoTableRequest.on` is a single string until
lance-namespace 0.12
([lance-namespace#363](https://github.com/lance-format/lance-namespace/pull/363),
[lance#8915](https://github.com/lance-format/lance/pull/8915)). There is
nothing in this repo's Java SDK to change until the `lance-core` pin can
move.

Sending more than one column requires a server that accepts the repeated
parameter ([sophon#7571](https://github.com/lancedb/sophon/pull/7571));
an older server returns a 400 rather than silently merging on one
column.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 16:32:04 -07:00
Bruno Ramirez 9a1ffb9e02 fix(remote): forward create index replace flag (#4115)
Remote create-index requests already expose `replace` on the builder,
but the remote client did not consistently forward an explicit
`replace=false` over REST. That meant create-only intent could be lost
before it reached a remote server, even though local builders and Python
APIs can express it. This PR forwards `replace=false` on the existing
`create_index` endpoint and keeps the current default behavior unchanged
for compatibility.

This was accomplished with the following changes:

- Serialize `replace: false` into the existing remote create-index
request body when the builder is configured with `.replace(false)`.
- Forward `replace` through the synchronous Python remote `create_index`
wrapper so `RemoteTable.create_index(..., replace=False)` reaches the
repaired path.
- Continue omitting `replace` for the default path so existing remote
create-index requests keep their current semantics.
- Document `name` and `replace` on the existing OpenAPI create-index
request schema.
- Add coverage that verifies the remote client uses the existing
`/create_index/` route and forwards `replace=false`, including the
synchronous Python unified API.

### Testing

- `cargo fmt --all --check`
- `cargo test -p lancedb --features remote
test_create_index_forwards_replace_false_on_existing_route --locked`
- `uv tool run maturin develop --extras tests,dev,embeddings`
- `uv run --frozen pytest
python/tests/test_remote_db.py::test_remote_create_index_new_api`
- `uv run ruff format --check python/lancedb/remote/table.py
python/tests/test_remote_db.py`
- `cargo build -p lancedb --features remote --locked`
- `cargo clippy -p lancedb --features remote --all-targets --locked --
-D warnings`
2026-09-01 11:54:53 -07:00
LanceDB Robot f2eb4a245d chore: update lance dependency to v12.0.0-beta.9 (#4116)
Updates Lance dependencies from v12.0.0-beta.5 to v12.0.0-beta.9 across
Rust and Java. No compatibility fixes were required; full workspace
Clippy passes with all features.

Lance tag:
https://github.com/lance-format/lance/releases/tag/v12.0.0-beta.9
2026-09-02 00:25:27 +08:00
Xuanwo e6867f7d04 feat: support nested blob function signatures (#4109)
Function signatures currently reject Blob v2 fields nested inside
structs, preventing UDFs from accepting or returning structured values
that contain blobs.

Accept canonical Blob v2 fields as direct or recursive struct children
while preserving exact field metadata and nullability. Blob fields under
list, large-list, fixed-size-list, or map ancestors remain rejected
because collection runtime adaptation is outside the supported Function
ABI.

A whole named struct result can bind directly to one destination column
without introducing an extra wrapper level.
2026-09-01 23:51:08 +08:00
Xuanwo 193c5e3458 feat: add list_functions client APIs (#4108)
Function registration and exact lookup are exposed through the SDK, but
clients cannot discover published versions even though the server
provides `POST /v1/functions/list`.

Add Rust and Python sync/async `list_functions()` APIs that return typed
`FunctionVersion` values. The remote client requests canonical
definitions and follows opaque page tokens until the listing is
complete, including empty intermediate pages, while preserving the
server's name/version ordering. Local databases retain the existing
Function-catalog unsupported error.

The SDK consumes protocol pagination internally so callers receive the
complete catalog rather than handling server-specific page tokens.
2026-09-01 23:50:57 +08:00
Lance Release 7ebd3c222d Bump version: 0.38.0 → 0.39.0-beta.0 2026-09-01 13:16:03 +00:00
Wyatt Alt d118ef168b feat: record the source namespace in a materialized view definition (#4098)
A view definition recorded its source by bare name and refresh resolved
that name at the root, so declaring a view over a namespaced source was
refused outright -- materialized views were root-only for every caller.

The definition now carries `source_namespace`, and refresh opens the
source at that coordinate. `plan` takes the namespace too: refresh
re-plans the stored definition and persists the result when it migrates,
so defaulting it there would strand the view on its next rebuild.

The stored kind is the version boundary. Root definitions keep the
`select` form byte-for-byte, so everything written before this change
reads exactly as it always did. A namespaced source is stored as
`namespaced_select`: released readers drop unknown fields and resolve a
`select` source at the root, so keeping the old kind would let a
rolled-back worker refresh a view from a same-name root table -- the new
kind routes them to their existing unrecognized-kind refusal instead.
The Python and Node definition parsers learn the new kind alongside the
Rust core.
2026-09-01 06:05:02 -07:00
lancedb-gatefixer[bot]andXuanwo 19232f9c50 fix: preserve duplicate take offsets (#4024)
## Summary
- preserve repeated table offsets without adding a public ordering
guarantee
- retain exact requested ordering in identity and persisted permutations
- cover local, projected, multi-batch, and mocked-remote query paths

## Root cause
Take queries lowered offsets to a set-like IN predicate and discarded
repeated occurrences. Persisted permutation loading also compared the
distinct base-table result count with the requested occurrence count,
rejecting repeated row IDs before its existing reordering step could
expand them.

## Fix
The shared take-query path now deduplicates the predicate for efficient
lookup, requests row-offset metadata internally, and expands each
matching row to the requested multiplicity in backend result order. An
internal opt-in keeps exact requested order for identity
PermutationReader reads, while persisted permutations continue using
their existing ordering map.

## Validation
- cargo test --quiet --features remote --tests
- cargo check --quiet --features remote --tests --examples
- cargo clippy --quiet --features remote --tests --examples
- targeted Python local and mocked-remote regression tests
- exact issue reproduction

Fixes #2820

<!-- lance-gatekeeper-fix:v1 agent=75acf840afa6f4be4bff98b567b504bd
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-09-01 20:08:47 +08:00
Xuanwo 5cbd979455 fix: preserve namespace drop errors (#4099)
## Summary

- preserve typed namespace errors returned by `drop_table`
- return `TableNotFound` when dropping an absent namespace table
- cover repeated drop behavior in the namespace database test

## Validation

- `cargo test --quiet --features remote -p lancedb
database::namespace::tests::test_namespace_drop_table --lib`
- `cargo clippy --quiet --features remote -p lancedb --lib --tests -- -D
warnings`

## Context

Sophon SQL implements `DROP TABLE IF EXISTS` by matching
`lancedb::Error::TableNotFound`. The namespace database previously
wrapped this error as `Runtime`, causing cleanup to fail and mask an
earlier statement error.
2026-08-31 14:02:40 -07:00
e773d1e093 build(deps): bump the rust-minor-patch group across 1 directory with 9 updates (#4084)
Bumps the rust-minor-patch group with 9 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [async-trait](https://github.com/dtolnay/async-trait) | `0.1.91` |
`0.1.92` |
| [log](https://github.com/rust-lang/log) | `0.4.33` | `0.4.34` |
| [moka](https://github.com/moka-rs/moka) | `0.12.15` | `0.12.16` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.24.0` | `1.26.0` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.21.0` |
`3.22.0` |
| [roaring](https://github.com/RoaringBitmap/roaring-rs) | `0.11.4` |
`0.11.5` |
| [napi](https://github.com/napi-rs/napi-rs) | `3.11.0` | `3.12.0` |
| [napi-derive](https://github.com/napi-rs/napi-rs) | `3.6.1` | `3.6.3`
|
| [napi-build](https://github.com/napi-rs/napi-rs) | `2.4.0` | `2.4.1` |


Updates `async-trait` from 0.1.91 to 0.1.92
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/dtolnay/async-trait/releases">async-trait's
releases</a>.</em></p>
<blockquote>
<h2>0.1.92</h2>
<ul>
<li>Resolve double_must_use clippy lint in generated code (<a
href="https://redirect.github.com/dtolnay/async-trait/issues/303">#303</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/dtolnay/async-trait/commit/82e7e9edd60f622294373a23c0ce9c0077ad0263"><code>82e7e9e</code></a>
Release 0.1.92</li>
<li><a
href="https://github.com/dtolnay/async-trait/commit/9a35cb87f9366cd992bbc00d430e1b5fe1aa0cdd"><code>9a35cb8</code></a>
Merge pull request <a
href="https://redirect.github.com/dtolnay/async-trait/issues/303">#303</a>
from dtolnay/mustuse</li>
<li><a
href="https://github.com/dtolnay/async-trait/commit/875ceecb100bab2cf369178633b4791336d92b75"><code>875ceec</code></a>
Resolve double_must_use clippy lint</li>
<li><a
href="https://github.com/dtolnay/async-trait/commit/62993a57bc6a8d5bd3de23fbae48cede333cb925"><code>62993a5</code></a>
Raise minimum tested compiler to rust 1.88</li>
<li>See full diff in <a
href="https://github.com/dtolnay/async-trait/compare/0.1.91...0.1.92">compare
view</a></li>
</ul>
</details>
<br />

Updates `log` from 0.4.33 to 0.4.34
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/log/releases">log's
releases</a>.</em></p>
<blockquote>
<h2>0.4.34</h2>
<h2>What's Changed</h2>
<ul>
<li>doc: Add context-logger utility to README by <a
href="https://github.com/alekseysidorov"><code>@​alekseysidorov</code></a>
in <a
href="https://redirect.github.com/rust-lang/log/pull/735">rust-lang/log#735</a></li>
<li>Add alloc support for boxed loggers by <a
href="https://github.com/malezjaa"><code>@​malezjaa</code></a> in <a
href="https://redirect.github.com/rust-lang/log/pull/737">rust-lang/log#737</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/alekseysidorov"><code>@​alekseysidorov</code></a>
made their first contribution in <a
href="https://redirect.github.com/rust-lang/log/pull/735">rust-lang/log#735</a></li>
<li><a href="https://github.com/malezjaa"><code>@​malezjaa</code></a>
made their first contribution in <a
href="https://redirect.github.com/rust-lang/log/pull/737">rust-lang/log#737</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rust-lang/log/compare/0.4.33...0.4.34">https://github.com/rust-lang/log/compare/0.4.33...0.4.34</a></p>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/rust-lang/log/blob/master/CHANGELOG.md">log's
changelog</a>.</em></p>
<blockquote>
<h2>[0.4.34] - 2026-08-22</h2>
<h2>What's Changed</h2>
<ul>
<li>doc: Add context-logger utility to README by <a
href="https://github.com/alekseysidorov"><code>@​alekseysidorov</code></a>
in <a
href="https://redirect.github.com/rust-lang/log/pull/735">rust-lang/log#735</a></li>
<li>Add alloc support for boxed loggers by <a
href="https://github.com/malezjaa"><code>@​malezjaa</code></a> in <a
href="https://redirect.github.com/rust-lang/log/pull/737">rust-lang/log#737</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/alekseysidorov"><code>@​alekseysidorov</code></a>
made their first contribution in <a
href="https://redirect.github.com/rust-lang/log/pull/735">rust-lang/log#735</a></li>
<li><a href="https://github.com/malezjaa"><code>@​malezjaa</code></a>
made their first contribution in <a
href="https://redirect.github.com/rust-lang/log/pull/737">rust-lang/log#737</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/rust-lang/log/compare/0.4.33...0.4.34">https://github.com/rust-lang/log/compare/0.4.33...0.4.34</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/rust-lang/log/commit/8034743dd9d7f7583bd9a670271483d176130911"><code>8034743</code></a>
Merge pull request <a
href="https://redirect.github.com/rust-lang/log/issues/738">#738</a>
from rust-lang/cargo/0.4.34</li>
<li><a
href="https://github.com/rust-lang/log/commit/7d1e24e3506d4ffa1badf6c9ea357779877adaf0"><code>7d1e24e</code></a>
prepare for 0.4.34 release</li>
<li><a
href="https://github.com/rust-lang/log/commit/3b939b6714616dc32193c12019861c7c518c5edb"><code>3b939b6</code></a>
Merge pull request <a
href="https://redirect.github.com/rust-lang/log/issues/737">#737</a>
from malezjaa/master</li>
<li><a
href="https://github.com/rust-lang/log/commit/b88266cfed8b287f8c35b2015808b09b056f61af"><code>b88266c</code></a>
Add alloc support for boxed loggers</li>
<li><a
href="https://github.com/rust-lang/log/commit/037d7a58f6ad184abb3afc4db81d37c43a5696ec"><code>037d7a5</code></a>
doc: Add context-logger utility to README</li>
<li>See full diff in <a
href="https://github.com/rust-lang/log/compare/0.4.33...0.4.34">compare
view</a></li>
</ul>
</details>
<br />

Updates `moka` from 0.12.15 to 0.12.16
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/moka-rs/moka/releases">moka's
releases</a>.</em></p>
<blockquote>
<h2>v0.12.16</h2>
<h2>Version 0.12.16</h2>
<h3>Fixed</h3>
<ul>
<li>Fixed a bug where cache eviction could stall permanently when the
cache was configured with the <strong>non-default</strong> LRU eviction
policy (<code>EvictionPolicy::lru()</code>) by a race between insert and
remove operations on the same key (<a
href="https://redirect.github.com/moka-rs/moka/issues/592">#592</a><a
href="https://redirect.github.com/moka-rs/moka/pull/592/">gh-pull-0592</a>
by <a
href="https://github.com/kim-jhyeon"><code>@​kim-jhyeon</code></a>,
reported in <a
href="https://redirect.github.com/moka-rs/moka/issues/590">#590</a><a
href="https://redirect.github.com/moka-rs/moka/issues/590/">gh-issue-0590</a>):
<ul>
<li>This bug was introduced in v0.12.0 and affected
<code>sync::Cache</code>, <code>sync::SegmentedCache</code> and
<code>future::Cache</code>.</li>
<li>A race between applying a write recording for an entry and
concurrently removing that entry from the internal concurrent hash table
could leave an orphaned node at the front of the LRU queue. Once
present, no entry was ever evicted again and the cache grew unboundedly
past <code>max_capacity</code>.</li>
<li>The same race also affected the default TinyLFU eviction policy, but
with a milder symptom: each occurrence permanently leaked one phantom
entry slot, causing <code>entry_count</code> and
<code>weighted_size</code> to over-report and the usable capacity to
shrink by one entry per occurrence. Fixed by the same change.</li>
</ul>
</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Worked around a ThreadSanitizer false positive (<a
href="https://redirect.github.com/moka-rs/moka/issues/602">#602</a><a
href="https://redirect.github.com/moka-rs/moka/pull/602/">gh-pull-0602</a>):
<ul>
<li>Replaced the standalone <code>fence(Acquire)</code> in the internal
<code>MiniArc</code>'s drop path with an <code>Acquire</code> load of
the reference count, so that downstream projects can now run
ThreadSanitizer on code using Moka without hitting this false
positive.</li>
<li><code>std::sync::Arc</code> has a similar workaround.</li>
</ul>
</li>
<li>Raised the minimum version of the <code>crossbeam-epoch</code> crate
from <code>v0.9.18</code> to <code>v0.9.20</code> to avoid the following
advisory (<a
href="https://redirect.github.com/moka-rs/moka/issues/603">#603</a><a
href="https://redirect.github.com/moka-rs/moka/pull/603/">gh-pull-0603</a>):
<ul>
<li>[RUSTSEC-2026-0204] crossbeam-epoch: invalid pointer dereference in
<code>fmt::Pointer</code> for <code>Atomic</code> and
<code>Shared</code></li>
<li>Moka is <em>not</em> affected by this advisory because it never
formats these pointer types. However, raising the minimum version
prevents downstream lockfiles from resolving to an affected
<code>crossbeam-epoch</code> version via Moka.</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Changelog</summary>
<p><em>Sourced from <a
href="https://github.com/moka-rs/moka/blob/main/CHANGELOG.md">moka's
changelog</a>.</em></p>
<blockquote>
<h2>Version 0.12.16</h2>
<h3>Fixed</h3>
<ul>
<li>Fixed a bug where cache eviction could stall permanently when the
cache was
configured with the <strong>non-default</strong> LRU eviction policy
(<code>EvictionPolicy::lru()</code>)
by a race between insert and remove operations on the same key
(<a
href="https://redirect.github.com/moka-rs/moka/issues/592">#592</a>[gh-pull-0592]
by [<a
href="https://github.com/kim-jhyeon"><code>@​kim-jhyeon</code></a>][gh-kim-jhyeon],
reported in
<a
href="https://redirect.github.com/moka-rs/moka/issues/590">#590</a>[gh-issue-0590]):
<ul>
<li>This bug was introduced in v0.12.0 and affected
<code>sync::Cache</code>,
<code>sync::SegmentedCache</code> and <code>future::Cache</code>.</li>
<li>A race between applying a write recording for an entry and
concurrently
removing that entry from the internal concurrent hash table could leave
an
orphaned node at the front of the LRU queue. Once present, no entry was
ever
evicted again and the cache grew unboundedly past
<code>max_capacity</code>.</li>
<li>The same race also affected the default TinyLFU eviction policy, but
with
a milder symptom: each occurrence permanently leaked one phantom entry
slot, causing <code>entry_count</code> and <code>weighted_size</code> to
over-report and the
usable capacity to shrink by one entry per occurrence. Fixed by the same
change.</li>
</ul>
</li>
</ul>
<h3>Changed</h3>
<ul>
<li>Worked around a ThreadSanitizer false positive (<a
href="https://redirect.github.com/moka-rs/moka/issues/602">#602</a>[gh-pull-0602]):
<ul>
<li>Replaced the standalone <code>fence(Acquire)</code> in the internal
<code>MiniArc</code>'s drop
path with an <code>Acquire</code> load of the reference count, so that
downstream
projects can now run ThreadSanitizer on code using Moka without hitting
this false positive.</li>
<li><code>std::sync::Arc</code> has a similar workaround.</li>
</ul>
</li>
<li>Raised the minimum version of the <code>crossbeam-epoch</code> crate
from <code>v0.9.18</code> to
<code>v0.9.20</code> to avoid the following advisory (<a
href="https://redirect.github.com/moka-rs/moka/issues/603">#603</a>[gh-pull-0603]):
<ul>
<li>[RUSTSEC-2026-0204] crossbeam-epoch: invalid pointer dereference in
<code>fmt::Pointer</code> for <code>Atomic</code> and
<code>Shared</code></li>
<li>Moka is <em>not</em> affected by this advisory because it never
formats these
pointer types. However, raising the minimum version prevents downstream
lockfiles from resolving to an affected <code>crossbeam-epoch</code>
version via
Moka.</li>
</ul>
</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/moka-rs/moka/commit/a616ec19e8d4ed938caf8b2c88090331d778d5da"><code>a616ec1</code></a>
Merge pull request <a
href="https://redirect.github.com/moka-rs/moka/issues/604">#604</a> from
moka-rs/chore/bump-v0.12.16</li>
<li><a
href="https://github.com/moka-rs/moka/commit/3b140a627e9faa4ec6a8e682224c7f81efc2b6e4"><code>3b140a6</code></a>
Bump the version to v0.12.16</li>
<li><a
href="https://github.com/moka-rs/moka/commit/51b802dc5cfc9e0da21de04177d79028bfbe5d47"><code>51b802d</code></a>
Merge pull request <a
href="https://redirect.github.com/moka-rs/moka/issues/603">#603</a> from
moka-rs/bump-crossbeam-epoch-floor</li>
<li><a
href="https://github.com/moka-rs/moka/commit/4f9071684161d59212c32a0e89762c3a5d6385a4"><code>4f90716</code></a>
Raise the minimum crossbeam-epoch version to 0.9.20</li>
<li><a
href="https://github.com/moka-rs/moka/commit/08d0e0458bd95af7f9435ff3ffbba6d1e91647c1"><code>08d0e04</code></a>
Merge pull request <a
href="https://redirect.github.com/moka-rs/moka/issues/602">#602</a> from
moka-rs/gh600-tsan-workaround</li>
<li><a
href="https://github.com/moka-rs/moka/commit/14447a7cbe441639e2aa3570e411fe493c71c9ac"><code>14447a7</code></a>
Restructure the v0.12.16 TSan workaround CHANGELOG entry</li>
<li><a
href="https://github.com/moka-rs/moka/commit/7b14c37b009a25c9a2ec27e2669dc5f8db7ce254"><code>7b14c37</code></a>
Avoid a TSan false positive by replacing the fence in MiniArc::drop</li>
<li><a
href="https://github.com/moka-rs/moka/commit/05b37c63098473034e7e961c1010284163ad8634"><code>05b37c6</code></a>
Merge pull request <a
href="https://redirect.github.com/moka-rs/moka/issues/599">#599</a> from
moka-rs/gh590-deterministic-tests</li>
<li><a
href="https://github.com/moka-rs/moka/commit/fc318584d25c0ea01109872da37d395754647e04"><code>fc31858</code></a>
Replace private doc references in gh590 test comments</li>
<li><a
href="https://github.com/moka-rs/moka/commit/57435922036ff4ab9f1b5bd0c3bffe6c8acd9921"><code>5743592</code></a>
Improve the v0.12.16 CHANGELOG entry</li>
<li>Additional commits viewable in <a
href="https://github.com/moka-rs/moka/compare/v0.12.15...v0.12.16">compare
view</a></li>
</ul>
</details>
<br />

Updates `uuid` from 1.24.0 to 1.26.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/uuid-rs/uuid/releases">uuid's
releases</a>.</em></p>
<blockquote>
<h2>v1.26.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Add ContextV7::with_additional_precision_bits by <a
href="https://github.com/ChrisJr404"><code>@​ChrisJr404</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/904">uuid-rs/uuid#904</a></li>
<li>Prepare for 1.26.0 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/905">uuid-rs/uuid#905</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/1.25.0...v1.26.0">https://github.com/uuid-rs/uuid/compare/1.25.0...v1.26.0</a></p>
<h2>1.25.0</h2>
<h2>What's Changed</h2>
<ul>
<li>Add a serde::bytes module that encodes a Uuid as a byte string by <a
href="https://github.com/ChrisJr404"><code>@​ChrisJr404</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/902">uuid-rs/uuid#902</a></li>
<li>Prepare for 1.25.0 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/903">uuid-rs/uuid#903</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/ChrisJr404"><code>@​ChrisJr404</code></a> made
their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/902">uuid-rs/uuid#902</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.24.1...1.25.0">https://github.com/uuid-rs/uuid/compare/v1.24.1...1.25.0</a></p>
<h2>v1.24.1</h2>
<h2>What's Changed</h2>
<ul>
<li>Fix non-ASCII character handling in parse diagnostics by <a
href="https://github.com/questfever"><code>@​questfever</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/899">uuid-rs/uuid#899</a></li>
<li>Prepare for 1.24.1 release by <a
href="https://github.com/KodrAus"><code>@​KodrAus</code></a> in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/900">uuid-rs/uuid#900</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/questfever"><code>@​questfever</code></a> made
their first contribution in <a
href="https://redirect.github.com/uuid-rs/uuid/pull/899">uuid-rs/uuid#899</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1">https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.24.1</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/uuid-rs/uuid/commit/cdc96a87bddc38d0eb8f894c764e151d2299b4b3"><code>cdc96a8</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/905">#905</a> from
uuid-rs/cargo/v1.26.0</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/34e4f49c0d50c12f1b3021baf98b8fb91f6407bb"><code>34e4f49</code></a>
don't test macros under miri</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/d9e7242b37755d844d19fa74559a88e1c46c5206"><code>d9e7242</code></a>
update nightly used for miri</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/ec16819865b89aa3c52456c8afd0ce9a90f0fcdb"><code>ec16819</code></a>
prepare for 1.26.0 release</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/162cd208a4521138f1d8ce05b63342ba7ba5c4e6"><code>162cd20</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/904">#904</a> from
ChrisJr404/v7-additional-precision-bits</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/97eceffa708f87969792af604291d3e4984dfc90"><code>97eceff</code></a>
Add ContextV7::with_additional_precision_bits for microsecond
clocks</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/302e0bf6dc5abf949c06973a37f1f3a093cc2699"><code>302e0bf</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/903">#903</a> from
uuid-rs/cargo/1.25.0</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/b7ccde885d770d013f413a2685ebe7f38932e1d0"><code>b7ccde8</code></a>
prepare for 1.25.0 release</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/c62dffbc038034ff045f3009f2536362e313bf34"><code>c62dffb</code></a>
Merge pull request <a
href="https://redirect.github.com/uuid-rs/uuid/issues/902">#902</a> from
ChrisJr404/serde-bytes-module</li>
<li><a
href="https://github.com/uuid-rs/uuid/commit/8c198b24b1aa55948c0fa4b3433c1954be19c8c8"><code>8c198b2</code></a>
Add a serde::bytes module that encodes as a byte string</li>
<li>Additional commits viewable in <a
href="https://github.com/uuid-rs/uuid/compare/v1.24.0...v1.26.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `serde_with` from 3.21.0 to 3.22.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/jonasbb/serde_with/releases">serde_with's
releases</a>.</em></p>
<blockquote>
<h2>serde_with v3.22.0</h2>
<h3>Added</h3>
<ul>
<li>Add support for <code>jiff</code> v0.2 behind the new
<code>jiff_0_2</code> feature flag (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/936">#936</a>)
<code>jiff::SignedDuration</code> works with
<code>DurationSeconds</code> and its variants.
<code>jiff::Timestamp</code>, <code>jiff::Zoned</code>, and
<code>jiff::civil::DateTime</code> work with
<code>TimestampSeconds</code> and its variants.
Deserializing a <code>jiff::Zoned</code> uses the system time zone, like
<code>chrono::DateTime&lt;Local&gt;</code>.</li>
</ul>
<h3>Fixed</h3>
<ul>
<li>Extend the <a
href="https://github.com/jonasbb/serde_with/security/advisories/GHSA-7gcf-g7xr-8hxj">GHSA-7gcf-g7xr-8hxj</a>
fix to the duplicate-key-prevention collections.
The <code>rust::sets_duplicate_value_is_error</code>,
<code>rust::maps_duplicate_key_is_error</code>,
<code>rust::sets_last_value_wins</code>, and
<code>rust::maps_first_key_wins</code> adapters created their backing
sets/maps with <code>with_capacity_and_hasher</code> using the raw
deserializer <code>size_hint</code>, bypassing the
<code>size_hint_cautious</code> cap added in <a
href="https://redirect.github.com/jonasbb/serde_with/issues/966">#966</a>
(the <code>clippy.toml</code> <code>disallowed_methods</code> lint only
covers <code>Vec::with_capacity</code>, not
<code>with_capacity_and_hasher</code>, so these sites were not flagged).
Attacker-controlled input claiming a huge length could panic with
<code>Hash table capacity overflow</code> before a single element was
read. All such constructions now route through
<code>size_hint_cautious</code>.</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/jonasbb/serde_with/commit/88f576a17c5cd45cea6a30252ef10653dde69fa8"><code>88f576a</code></a>
Bump version to 3.22.0 (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/991">#991</a>)</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/931e664445c2139446b84e76b339924f136a1565"><code>931e664</code></a>
Bump version to 3.22.0</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/e26930e0b7a6c1e6463086a2447e7cc8fc6f0a24"><code>e26930e</code></a>
Bump github/codeql-action from 4.37.3 to 4.37.4 in the github-actions
group (...</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/92cd5a0bd5c7a80fc7eae90bb99b873c40429aa3"><code>92cd5a0</code></a>
Bump github/codeql-action in the github-actions group</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/32be66fecc5c1fe4c90ac0230c0af04d1977df53"><code>32be66f</code></a>
Guard with_capacity_and_hasher against untrusted size_hint (DoS) (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/971">#971</a>)</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/33871cd4dd1ecef2c3af0ead9528c8b407c16f04"><code>33871cd</code></a>
Merge branch 'master' into
fix/duplicate-key-impls-capacity-overflow</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/bb1e06484261595c8cec7fd7f4ed33ecdfb951c0"><code>bb1e064</code></a>
Change function position within impl (<a
href="https://redirect.github.com/jonasbb/serde_with/issues/968">#968</a>)</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/202d3dd617d7b5a9db5f490fa752d6ccb48454e8"><code>202d3dd</code></a>
Improve the time unit macros to remove unnecessary repetition and make
the co...</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/b347efb536caf83c850d4808f90503835fd78755"><code>b347efb</code></a>
Move the <code>use_duration_signed_ser</code>/<code>*_de</code> macros
utils</li>
<li><a
href="https://github.com/jonasbb/serde_with/commit/65905455527c0abf51f2f906bc08724426b2b922"><code>6590545</code></a>
chrono_0_4: Implement the same time unit macro cleanup as jiff_0_2</li>
<li>Additional commits viewable in <a
href="https://github.com/jonasbb/serde_with/compare/v3.21.0...v3.22.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `roaring` from 0.11.4 to 0.11.5
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/RoaringBitmap/roaring-rs/releases">roaring's
releases</a>.</em></p>
<blockquote>
<h2>v0.11.5</h2>
<h2>What's Changed</h2>
<ul>
<li>Implement std Error for IntegerTooSmall by <a
href="https://github.com/Kerollmops"><code>@​Kerollmops</code></a> in <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/pull/362">RoaringBitmap/roaring-rs#362</a></li>
<li>fix: invalid treemap iter advance by <a
href="https://github.com/silver-ymz"><code>@​silver-ymz</code></a> in <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/pull/360">RoaringBitmap/roaring-rs#360</a></li>
<li>Fix off-by-one that corrupts a bitmap in
remove_smallest/remove_biggest (<a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/issues/359">#359</a>)
by <a href="https://github.com/youdie006"><code>@​youdie006</code></a>
in <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/pull/363">RoaringBitmap/roaring-rs#363</a></li>
<li>Upgrade dependencies bump version by <a
href="https://github.com/Kerollmops"><code>@​Kerollmops</code></a> in <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/pull/364">RoaringBitmap/roaring-rs#364</a></li>
</ul>
<h2>New Contributors</h2>
<ul>
<li><a
href="https://github.com/silver-ymz"><code>@​silver-ymz</code></a> made
their first contribution in <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/pull/360">RoaringBitmap/roaring-rs#360</a></li>
<li><a href="https://github.com/youdie006"><code>@​youdie006</code></a>
made their first contribution in <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/pull/363">RoaringBitmap/roaring-rs#363</a></li>
</ul>
<p><strong>Full Changelog</strong>: <a
href="https://github.com/RoaringBitmap/roaring-rs/compare/v0.11.4...v0.11.5">https://github.com/RoaringBitmap/roaring-rs/compare/v0.11.4...v0.11.5</a></p>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/0ce3fc8b55b193ce220253bfbc0c3e09bd171375"><code>0ce3fc8</code></a>
Merge pull request <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/issues/364">#364</a>
from RoaringBitmap/upgrade-dependencies-bump-version</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/a961a042db8d325515e6b5273a2e9369fe5c931d"><code>a961a04</code></a>
Remove the once_cell dependency</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/5e8445b2d6914e8e56d85de340f9156825f4e91b"><code>5e8445b</code></a>
Merge pull request <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/issues/363">#363</a>
from youdie006/fix/359-interval-remove-boundary</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/bf2961d99fb4da2c540a55eb699228a7bb00a732"><code>bf2961d</code></a>
Bump version to v0.11.5</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/048a8b05fcae08636354607f0c00d6e95f262107"><code>048a8b0</code></a>
Fix off-by-one that corrupts a bitmap in
remove_smallest/remove_biggest</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/27d84f567dd85243194d2b87683262ef43a5dd97"><code>27d84f5</code></a>
Merge pull request <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/issues/360">#360</a>
from silver-ymz/fix/treemap-iter-advance-across-bitmaps</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/aac2de82a7f9e44fd73d8364de840169447d580b"><code>aac2de8</code></a>
Make clippy happy</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/a3d1d54be985fe22c01e882f85c3ee7055ad9c8b"><code>a3d1d54</code></a>
Merge pull request <a
href="https://redirect.github.com/RoaringBitmap/roaring-rs/issues/362">#362</a>
from RoaringBitmap/std-error-for-integer-too-small</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/9a3c33e42c0b14bdd3f296313ee367526092aa81"><code>9a3c33e</code></a>
Implement std Error for IntegerTooSmall</li>
<li><a
href="https://github.com/RoaringBitmap/roaring-rs/commit/f46c0ffe90b6d5d52a93106253bb6fa51a08c137"><code>f46c0ff</code></a>
fix: invalid treemap iter advance</li>
<li>See full diff in <a
href="https://github.com/RoaringBitmap/roaring-rs/compare/v0.11.4...v0.11.5">compare
view</a></li>
</ul>
</details>
<br />

Updates `napi` from 3.11.0 to 3.12.0
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/napi-rs/napi-rs/releases">napi's
releases</a>.</em></p>
<blockquote>
<h2>napi-v3.12.0</h2>
<h3>Added</h3>
<ul>
<li><em>(cli)</em> support non-threaded WASI targets (<a
href="https://redirect.github.com/napi-rs/napi-rs/pull/3353">#3353</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/58bd87fa524a837a7c962ab4103e5588557ccd81"><code>58bd87f</code></a>
chore: release (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3414">#3414</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/9da87236dbc4fef99f066b7a130f4d0377308d44"><code>9da8723</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/8d22196aa98a1e6e70584561f5446d117d9c802c"><code>8d22196</code></a>
chore(deps): update dependency oxc-parser to ^0.142.0 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3422">#3422</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/abc30fbafc2e3967d499cef970c68b3edfefd850"><code>abc30fb</code></a>
build(deps): bump postcss from 8.5.17 to 8.5.23 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3421">#3421</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/55421392cbaa24d4df69419e4c6d4958fbcb6a12"><code>5542139</code></a>
build(deps): bump fast-xml-parser from 5.9.3 to 5.10.1 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3418">#3418</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/dc4ee8c89cc27ce30e239482199b3b3d786bf8b6"><code>dc4ee8c</code></a>
build(deps): bump fast-uri from 3.1.3 to 3.1.4 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3419">#3419</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/050d985196174b4be830cdb813d09e2705258455"><code>050d985</code></a>
feat(async-runtime): drain-linger surface + lock-free scheduler
internals (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3">#3</a>...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/e0b87086eefe0e7efeea6d269e9403c4be4ba9aa"><code>e0b8708</code></a>
chore(deps): update dependency oxc-parser to ^0.141.0 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3417">#3417</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/fc8494010697d078a93a528c3180271f6f187504"><code>fc84940</code></a>
chore(deps): update actions/setup-node action to v7 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3413">#3413</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/ee598db45985ef11e18c7340801c28bb2452b688"><code>ee598db</code></a>
build(deps): bump protobufjs from 7.6.4 to 7.6.5 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3410">#3410</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/napi-rs/napi-rs/compare/napi-v3.11.0...napi-v3.12.0">compare
view</a></li>
</ul>
</details>
<br />

Updates `napi-derive` from 3.6.1 to 3.6.3
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/napi-rs/napi-rs/releases">napi-derive's
releases</a>.</em></p>
<blockquote>
<h2>napi-derive-v3.6.3</h2>
<h3>Other</h3>
<ul>
<li>updated the following local packages: napi-derive-backend</li>
</ul>
<h2>napi-derive-v3.6.2</h2>
<h3>Other</h3>
<ul>
<li>updated the following local packages: napi-derive-backend</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/956e4525fea6a676ea3680b711382f167b899af9"><code>956e452</code></a>
chore: release (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3448">#3448</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/73048f5a7fdbd42cdc2f46f2d5ac60ef27417bfa"><code>73048f5</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/61fae8a1440ad8b7249f3cd7838fc2bafe00a906"><code>61fae8a</code></a>
fix(napi): stop unloading addons with live native code, preserve
non-Error re...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/93e86ce167095e84f2be2ae1c66a6c0bb96fec49"><code>93e86ce</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/2c905991899b9f12a0df4072c4bff6d62ef70d26"><code>2c90599</code></a>
fix(cli): support npm 12 pack output (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3449">#3449</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/360b1ec99ab0d001147d29e11c416c8338d3d1c9"><code>360b1ec</code></a>
fix(wasi): avoid randomness during module registration (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3447">#3447</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/b648c4090e7518ced18c9eca6059d27af3ab511b"><code>b648c40</code></a>
build(deps): bump nanoid from 3.3.16 to 3.3.18 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3446">#3446</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/ffda4efff4bc4ebb6bef1f629dd0a6f09dc8f210"><code>ffda4ef</code></a>
chore(deps): update dependency js-yaml to v4.3.1 [security] (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3445">#3445</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/387b0dc7986018e44a4a0b466b030dc414170411"><code>387b0dc</code></a>
feat(cli): size WASI browser worker pools from
navigator.hardwareConcurrency ...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/61e4346ce3d9a9c13e5c5dd6fb3b7d5e1b1d6e0d"><code>61e4346</code></a>
build(deps): bump fast-uri from 3.1.4 to 3.1.5 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3440">#3440</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/napi-rs/napi-rs/compare/napi-derive-v3.6.1...napi-derive-v3.6.3">compare
view</a></li>
</ul>
</details>
<br />

Updates `napi-build` from 2.4.0 to 2.4.1
<details>
<summary>Release notes</summary>
<p><em>Sourced from <a
href="https://github.com/napi-rs/napi-rs/releases">napi-build's
releases</a>.</em></p>
<blockquote>
<h2>napi-build-v2.4.1</h2>
<h3>Fixed</h3>
<ul>
<li><em>(napi)</em> stop unloading addons with live native code,
preserve non-Error rejections, and add the wasm teardown barrier (<a
href="https://redirect.github.com/napi-rs/napi-rs/pull/3423">#3423</a>)</li>
</ul>
</blockquote>
</details>
<details>
<summary>Commits</summary>
<ul>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/956e4525fea6a676ea3680b711382f167b899af9"><code>956e452</code></a>
chore: release (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3448">#3448</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/73048f5a7fdbd42cdc2f46f2d5ac60ef27417bfa"><code>73048f5</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/61fae8a1440ad8b7249f3cd7838fc2bafe00a906"><code>61fae8a</code></a>
fix(napi): stop unloading addons with live native code, preserve
non-Error re...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/93e86ce167095e84f2be2ae1c66a6c0bb96fec49"><code>93e86ce</code></a>
chore(release): publish</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/2c905991899b9f12a0df4072c4bff6d62ef70d26"><code>2c90599</code></a>
fix(cli): support npm 12 pack output (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3449">#3449</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/360b1ec99ab0d001147d29e11c416c8338d3d1c9"><code>360b1ec</code></a>
fix(wasi): avoid randomness during module registration (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3447">#3447</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/b648c4090e7518ced18c9eca6059d27af3ab511b"><code>b648c40</code></a>
build(deps): bump nanoid from 3.3.16 to 3.3.18 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3446">#3446</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/ffda4efff4bc4ebb6bef1f629dd0a6f09dc8f210"><code>ffda4ef</code></a>
chore(deps): update dependency js-yaml to v4.3.1 [security] (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3445">#3445</a>)</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/387b0dc7986018e44a4a0b466b030dc414170411"><code>387b0dc</code></a>
feat(cli): size WASI browser worker pools from
navigator.hardwareConcurrency ...</li>
<li><a
href="https://github.com/napi-rs/napi-rs/commit/61e4346ce3d9a9c13e5c5dd6fb3b7d5e1b1d6e0d"><code>61e4346</code></a>
build(deps): bump fast-uri from 3.1.4 to 3.1.5 (<a
href="https://redirect.github.com/napi-rs/napi-rs/issues/3440">#3440</a>)</li>
<li>Additional commits viewable in <a
href="https://github.com/napi-rs/napi-rs/compare/napi-build-v2.4.0...napi-build-v2.4.1">compare
view</a></li>
</ul>
</details>
<br />

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Will Jones <willjones127@gmail.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-31 10:40:11 -07:00
Yang Cen c8fd3e97d1 test(python): cover stable main udf registration identity (#4094)
## Other changes

### What changed?

- Add a subprocess regression harness for an ordinary `@udf` function
defined in `__main__`.
- Verify the full registration request, artifact digest, and Function
signature stay identical across independent Python processes and
renamed/moved script paths.
- Verify body, referenced-global, and annotation changes still produce
distinct artifact identities, with annotation changes also producing a
distinct Function signature.

### Why is the change needed?


[ENT-2441](https://linear.app/lancedb/issue/ENT-2441/make-sure-function-defined-in-main-gets-stable-signature)
tracks the stability guarantee. Investigation on the exact `840e1d73`
main base found that LanceDB already packages canonical source instead
of cloudpickle bytes, so the unchanged `__main__` function is stable and
no production-code fix is needed. This change closes the missing
regression-test coverage.


[GEN-950](https://linear.app/lancedb/issue/GEN-950/class-based-udfs-defined-in-main-get-a-new-auto-version-on-every-run)
remains a separate Geneva checkpoint-version issue for class-based
callables. LanceDB's Function API continues to accept synchronous Python
functions only.

## Validation

- `cd python && uv run --extra tests pytest
python/tests/test_first_class_function_slice2.py -q` (`40 passed`)
- `uv run --project python --extra dev ruff format .`
- `uv run --project python --extra dev ruff check .` (`All checks
passed!`)
2026-08-31 22:34:34 +08:00
Xuanwo c196d033e9 feat: add drop_function client APIs (#4097) 2026-08-31 22:17:11 +08:00
Yang CenandClaude 16753b805a revert: restore the lance v12.0.0-beta.5 pin on main (#4095)
Reverts #4093 (`c4ee8ae`), restoring main's lance dependency to
v12.0.0-beta.5 and the v12 integration surface it carried — the
`read_dir_page` paginated-listing pushdown from #3979, the v12
object-store wrapper APIs, and the shard-manifest call sites.

Pinning lance v11.0.0 stable belonged on a dedicated release branch for
cutting v0.38.0, not on main: main was already on the v12 beta train, so
#4093 was a downgrade of the development line. The released **v0.38.0
stands as published** — this only moves main forward again.

Verified on this branch: `cargo check --features remote --tests
--examples` clean, all 48 `database::listing` tests pass (the restored
store-pushdown pagination versions), `cargo fmt --check` and `cargo
clippy --features remote --tests --examples` clean. The root
`Cargo.lock` is restored by the revert and resolves as-is.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01BX7w9fUMQbPAXuxe9YgQKc

---
_Generated by [Claude
Code](https://claude.ai/code/session_01BX7w9fUMQbPAXuxe9YgQKc)_

Co-authored-by: Claude <noreply@anthropic.com>
2026-08-31 20:40:46 +08:00
Lance Release 840e1d7313 Bump version: 0.38.0-beta.16 → 0.38.0 2026-08-31 07:38:51 +00:00
Lance Release 1a9414c47c Bump version: 0.38.0-beta.15 → 0.38.0-beta.16 2026-08-31 07:38:25 +00:00
LanceDB Robot c4ee8ae670 feat: update lance dependency to v11.0.0 (#4093)
Updates the Rust workspace and Java lance-core dependency to Lance v11.0.0.

Includes compatibility adjustments for the Lance 11 object-store, table-listing, and shard-manifest APIs.
2026-08-31 15:35:09 +08:00
Lance Release 57b8d3bf05 Bump version: 0.38.0-beta.14 → 0.38.0-beta.15 2026-08-31 07:33:34 +00:00
Jack Ye c6dfe830d9 feat(python): support large_utf8 function signatures (#4092)
Teach the Python Function signature emitter to serialize PyArrow large
strings as the canonical Arrow type name `large_utf8`.

Extend the shared Function Arrow type fixture and explicit-schema
coverage for scalar, nested, list, and large-list compositions.
2026-08-31 00:32:04 -07:00
Jack Ye d5dac65a21 feat: support Blob v2 UDF signatures (#4091)
Make Function authoring and declaration planning treat Blob v2 as a
scalar semantic type while preserving exact Blob metadata in binding
schemas.

Covers scalar Blob outputs, expanded named-struct outputs, and
whole-result structs with Blob children.
2026-08-30 23:33:30 -07:00
Lance Release 1b0fc2c465 Bump version: 0.38.0-beta.13 → 0.38.0-beta.14 2026-08-30 15:16:33 +00:00
Xuanwo a417e46bfa feat(functions): support GPU resource requirements (#4085)
Functions can describe their Python environment today, but cannot
declare accelerator requirements. That prevents Sophon from scheduling
computed-column UDF refreshes onto GPU workers from the immutable
Function definition.

Add `num_gpus` to Python `@udf` through a typed
`FunctionResourceRequirements` value and represent resource-aware
definitions with the `python_v2` runtime discriminator. CPU Functions
retain their existing `python` encoding and canonical identity.

The new discriminator is intentional for mixed-version safety:
deployments that do not understand execution resources reject the
runtime instead of accepting a new field and silently running the
Function on CPU. Required resources are part of Function version
identity; priority, concurrency, and retry policy remain Job concerns.
The actual resource scheduling remains owned by Sophon.
2026-08-30 08:10:08 -07:00
Jack Ye fcdc3f949e fix: allow multiple function bindings per table (#4090)
Allow a remote Function declaration when the table already contains
valid, supported Function binding metadata. Existing bindings remain
fully validated, including fail-closed handling for newer or
inconsistent contracts, while other schema mutations retain their
existing no-binding guard. Add planner and remote request-path
regression coverage for a second binding and reject dependent Function
inputs, including nested paths.
2026-08-30 01:16:57 -07:00
Lance Release 0c4e0667bc Bump version: 0.38.0-beta.12 → 0.38.0-beta.13 2026-08-30 06:09:21 +00:00
Jack Ye 101f524e47 feat(python): support nested Function Arrow types (#4088)
Teach Python Function authoring to retain the compact V1 grammar for
existing types and emit canonical exact JSON for nested struct
signatures. Adds coverage for recursive struct/list schemas and exact
field properties.
2026-08-29 23:07:59 -07:00
LanceDB RobotandJack Ye 36c142fa2e chore: update lance dependency to v12.0.0-beta.5 (#4089)
Updates the Rust workspace and Java lance-core dependency to Lance
v12.0.0-beta.5. Includes minimal Rust 1.97 Clippy compatibility fixes
required by validation. Lance tag:
https://github.com/lance-format/lance/releases/tag/v12.0.0-beta.5

---------

Co-authored-by: Jack Ye <yezhaoqin@gmail.com>
2026-08-29 14:51:41 -07:00
Will JonesandClaude Opus 5 a87cada90e feat(node)!: require Node >= 22 and drop npm lockfiles (#4074)
The bindings are built, installed and published with pnpm everywhere,
but a parallel npm dependency graph was still being maintained beside
it. This removes it, raises the supported Node floor to the versions we
actually test, and gives Dependabot the npm coverage it was missing.

## Dropping npm

`nodejs/package-lock.json` was regenerated by `ci/update_lockfiles.sh`
on every release commit and read by nothing — no workflow runs `npm ci`
or `npm install` in `nodejs/`, and npm never publishes a lockfile in a
package tarball. It could not even agree with the real install, since
npm does not see pnpm's `overrides`. Because GitHub's dependency graph
parses `package-lock.json`, it was also reporting vulnerabilities for a
tree we neither install nor ship.

`docs/package.json`, `docs/package-lock.json` and `docs/tsconfig.json`
go too. They depend on `file:../node` and
`file:../node/node_modules/apache-arrow` — the `node/` directory was
removed long ago — the tsconfig compiles `src/*.ts` where no TypeScript
files exist, and nothing installs any of it. `docs.yml` only referenced
the lockfile to configure an npm cache for an install it never ran.

Two `workflow_dispatch` workflows for regenerating those lockfiles are
removed as well. Both were already broken: they `uses:` composite
actions at `.github/workflows/update_package_lock{,_nodejs}` that do not
exist, so dispatching either failed immediately.

The remaining `npx` calls become direct `node_modules/.bin/...`
invocations. These were already running locally installed binaries
rather than resolving anything, but naming the binary removes the npm
CLI from the loop and does not depend on which Node version is active.
`dev.yml`'s commitlint check was the last place doing real npm
dependency resolution — an unpinned `npm install
@commitlint/config-conventional` that also bypassed the
`minimumReleaseAge` hold configured for `nodejs/` — and is now a pinned
`pnpm dlx`.

## Node support

Node 18 and 20 both reached end-of-life, in April 2025 and April 2026.
The matrix moves to 22, 24 and 26, and `engines` rises from `>= 18` to
`>= 22` so the declared floor is one the matrix actually covers. Node 22
is LTS until April 2027; 24 is LTS; 26 is Current and becomes LTS in
October 2026.

This also removes the reason the workflows reached for `npx` in the
first place: pnpm 11 requires Node >= 22.13, which every matrix version
now satisfies.

The prebuilt-binary smoke test in `npm-publish.yml` moves from Node 20
to Node 22 — the floor, where a napi ABI problem would surface first —
rather than fanning out across all three, to keep the publish matrix
from tripling.

## Dependabot

There were no npm-ecosystem entries at all, which is why the advisories
behind #4073 went unnoticed. Both pnpm lockfiles are now watched —
`nodejs/` and `nodejs/examples/`, which is a separate install — using
the same `lockfile-only` strategy as the existing cargo and pip entries,
so version ranges in `package.json` are left alone.

## Pre-commit biome

The hook ran `npx @biomejs/biome@1.8.3` while `nodejs/package.json`
resolved 1.9.4. The two disagree about formatting, so the hook rejected
code that `pnpm lint` accepts, and failed on unmodified `main` for
anyone touching `nodejs/`. It now uses the pnpm-managed biome, which
fixes the drift with no source changes.

## Testing

`dev.yml`'s commitlint job does not check out the repo, so it runs in an
empty workspace, and I could not verify `pnpm/action-setup` there
locally. It triggers on `pull_request_target`, so this PR exercises it
directly — worth confirming green before merge. I did verify the `pnpm
dlx` invocation itself locally: it accepts a conventional title and
rejects a non-conventional one with exit 1.

Node 26 is new enough that the examples job may surface gaps in prebuilt
native binaries (`onnxruntime-node`, `sharp`) before their maintainers
publish for it.

## Not included

`nodejs/examples/` still pins `sharp: "0.33.5"` and has its own audit
findings. Raising the Node floor unblocks that work — sharp 0.35
requires Node >= 20.9, which the matrix now satisfies — but it is a
dependency bump rather than tooling cleanup, so it is left separate.

## Breaking changes

`@lancedb/lancedb` now requires Node >= 22; previously >= 18. The
`@types/node` peer range moves from `>=18` to `>=22` to match. Users on
Node 18 or 20 must upgrade their runtime; both have been end-of-life for
some time. Existing installs are unaffected, since `engines` is only
checked on install.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 09:47:47 -07:00
Xuanwo 0559108fa9 feat: support blob computed column refresh (#4081)
Computed-column planning currently sees Blob v2 storage descriptors, so
expressions cannot consume payload bytes or preserve Blob semantics in
their outputs.

A computed declaration now derives its output field from its expression.
A direct projection of a Blob v2 field inherits the source field's Blob
metadata; other expressions retain their ordinary Arrow-inferred type.
Declarations remain ordered, so the same rule applies across chained
projections.

Refresh materializes referenced Blob inputs as `LargeBinary` payload
bytes and publishes inherited Blob outputs through Lance's Blob
conversion path. Remote requests remain within the shared namespace
contract as `{name, computed}`; the server planner is being updated in
tandem to implement the same Blob-aware planning semantics, and remote
enablement must be aligned with that server rollout.

The existing null-as-unfilled contract remains unchanged. Row-level
freshness and cell flags remain follow-up work.
2026-08-28 17:23:49 +08:00
Will Jones 6ab3b9eb30 ci: upgrade chacha20 to 0.10.2 (#4078)
The pinned version was yanked due to UB in some SIMD kernels. Upgrading.
2026-08-27 19:06:07 -07:00
Lance Release c94d9a2a16 Bump version: 0.38.0-beta.11 → 0.38.0-beta.12 2026-08-28 00:52:01 +00:00
Wyatt Alt 6c8aa22704 feat: let a computed-column batch read its own earlier declarations (#4072)
`add_columns().computed()` accepted several columns in one call but
bound each against the table's schema as it stood before the call, so
`a` and `b = a + 1` had to be two commits. A server staging declarations
behind other schema work has no atomic way to do that, and a caller
reading the builder's plural signature reasonably expects the batch to
be one.

Each accepted column now joins the schema the next one resolves against,
so the batch is planned and committed as one. Order is the dependency
order; reading ahead is still an unknown column. `validate_declarations`
exposes the schema-level checks -- the Function-binding guard and the
planning -- without a commit, for callers that must reject before
earlier work in the same request lands; LSM state is table state and
stays a commit-time check.

Refresh order matters for a dependent column: `b = coalesce(a, 0)`
refreshed before `a` would bake zeros from `a`'s placeholder null, and
the fill-once contract keeps them. Refresh now refuses, naming the
input, while a computed input still has rows a refresh of it would fill
-- the same probe refresh already uses to detect a no-op. Otherwise it
is one snapshot and one commit, as before; a concurrent append is not in
the commit and waits for the next refresh. Refreshing dependencies on
the caller's behalf was considered and rejected: it is not how
materialized views or our own backfill scheduler behave, and it needs
multi-commit fencing that an explicit per-row fill marker would make
unnecessary.
2026-08-27 17:47:27 -07:00
Will JonesandClaude Opus 5 84f46df876 ci(nodejs): fix nightly OOM on the aarch64 publish legs (#4077)
The nightly `NPM Publish` run has failed every night since at least Aug
23, always on the same two legs: `aarch64-unknown-linux-gnu` and
`aarch64-unknown-linux-musl`. The other five targets pass. rustc is
OOM-killed during the fat-LTO codegen of the cdylib — `signal: 9` with
no diagnostic, about 27 minutes in — and on the musl leg that takes the
whole runner down with `The runner has received a shutdown signal`.

Both legs now pass:

| leg | before | peak memory | wall time |
| --- | --- | --- | --- |
| `aarch64-unknown-linux-gnu` | OOM-killed at ~27 min | 31391 → 22851
MiB | 38m43s → 22m04s |
| `aarch64-unknown-linux-musl` | runner killed at ~28 min | >32 GiB →
16516 MiB | ~40 min → 20m50s |

**ThinLTO** is most of that. Fat LTO is single-threaded, and its peak is
consumed inside rustc's LLVM before any linker process is spawned —
which is why it is the whole fix on musl, and why lld alone left the gnu
leg still peaking at 31391 MiB against the runner's 32 GiB. Both legs
now use the `lto: thin` / `codegen_units: 16` settings that darwin and
both Windows legs already use, at a cost of a few percent runtime
performance.

**lld** covers the rest, on the gnu leg. arm64 Linux otherwise links
through GNU `ld` where x86_64 already defaults to `rust-lld`, which is
why only the arm64 legs hit this at all; on a comparable arm64 build
(`lancedb/sophon#7313`) it cut the largest single linker process from
7.0 to 4.0 GiB and wall time by 35%. The flags live in a small wrapper
script used as the linker rather than in `-C link-arg`, because the
per-target rustflags variable does not reach every unit that links:
dependency crates linking a dylib (`crc-fast`, `lance-arrow`) were
invoked as bare `clang`, which targets the x86_64 host and fails with
`Relocations in generic ELF (EM: 183)`.

Separately, and affecting five legs rather than two: the three ThinLTO
targets exported `CARGO_PROFILE_RELEASE_LTO` and
`CARGO_PROFILE_RELEASE_CODEGEN_UNITS` from `pre_build`, which runs
inside the build step — after the cache step. `Swatinem/rust-cache`
computes its key when the action runs, before any step, so step-local
values are invisible to it. The result is a loop that never converges:
the key never changes, so restores are exact hits, an exact hit makes
the post-run save a no-op, and cargo invalidates the restored artifacts
anyway because the flags differ. Those legs have been rebuilding cold on
every run. Both values move to job-level `env:` ahead of the cache step,
driven by new `lto:`/`codegen_units:` matrix fields, and are forwarded
into the containers with `-e` since `docker run` inherits nothing.

Every leg's cache key shifts once as a result, so expect one cold
rebuild.

A `Report peak memory` step is added so whether these legs fit is a
number rather than an inference from whether the runner survived. It
produced the figures above.

## Not included

Moving these legs to native arm64 runners. It would retire the zig cross
path, the `AT_HWCAP2` workaround and the `TARGET_CC` override, and arm64
runners are billed roughly 37% below x64 at equal core count — but the
`lts-debian-aarch64` image exists to link against the manylinux2014
sysroot's glibc 2.17, and building natively on ubuntu-24.04 would raise
the minimum glibc for every published aarch64 binary. That is a
user-facing decision, not a CI cleanup.

Dropping these legs to smaller runners, which is where the real cost
saving is — larger runners are billed even on public repos. On these
numbers it is not available yet: musl at 16516 MiB is about 130 MiB over
what a 16 GB standard runner has. Worth revisiting as a follow-up.

## Testing

Cargo's rustflags precedence was checked locally rather than taken from
the docs, since getting it wrong would silently change the published
binaries. With a throwaway crate carrying both a `target.'cfg(all())'`
and a per-target rustflags table: setting `RUSTFLAGS` discards both, and
setting it to the empty string discards them too. That rules out routing
the linker flag through a job-level `RUSTFLAGS`, because `env:` keys
cannot be conditionally omitted and every other leg would then silently
lose the `target-cpu`/`target-feature` settings in `.cargo/config.toml`
— `+avx2` on x86_64 and `-crt-static` on aarch64-musl.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 17:35:06 -07:00
Drew 83cff3ab93 fix(python): use one blobv2 type and coerce blob writes by metadata (#4065) 2026-08-27 17:07:29 -07:00
Will JonesandClaude Opus 5 b85776c22a fix(listing)!: page table listings from the store's own cursor (#3979)
BREAKING CHANGE: list_tables now provides tables in arbitrary order and
the page token is now completely opaque. `table_names` retains the old
behavior of lexical ordering and `start-after` semantics.

Listing the tables in a directory database cost what the database held
rather than what the page held. `ListingDatabase::list_tables`
enumerated every child directory of the base path, sorted the names,
then discarded all but the requested page — on every request, for every
page. On object storage that is one full listing per page.

This PR pages the store instead. `list_tables` asks for one page at a
time through `ObjectStore::read_dir_page`, carrying the store's own
continuation token, so a page is one request. Non-table children can
leave a page short of its limit, so the walk continues until the page is
full or the store runs out.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 15:52:41 -07:00
lancedb-gatefixer[bot]andXuanwo 9d3962686e fix(node): accept Arrow metadata across JavaScript realms (#3904)
## Summary

- accept genuine Arrow metadata maps created in another JavaScript realm
- validate every metadata entry and clone it into a local Map
- cover an Arrow 15 VM-realm table through the public fromDataToBuffer
boundary
- retain structural typing for nested and dictionary Arrow data

## Root cause

The sanitizer used a local-realm instanceof Map check for schema and
field metadata. A genuine Map created in another JavaScript realm has
the required internal Map state but fails that identity check, so
fromDataToBuffer rejected the foreign table before serializing its rows.

## Scope

This fixes the distinct JavaScript-realm sanitizer failure identified
during review. It does not establish the cause of the S3/compaction
panic reported in #1525, so that issue remains open.

## Validation

- pnpm test --runInBand (707 passed, 5 skipped)
- pnpm test --runInBand __test__/arrow.test.ts (189 passed)
- pnpm build
- pnpm lint
- pnpm run docs

Related to #1525

<!-- lance-gatekeeper-fix:v1 agent=b522628ad3bae914eb7266ccd899d508
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 23:35:02 +08:00
lancedb-gatefixer[bot]andXuanwo 25645d82d4 feat(python): accept expressions in update filters (#3876)
## Summary
- allow Python sync, async, and remote table updates to accept type-safe
`Expr` filters
- serialize expression filters before invoking the existing update
implementation
- cover numeric-looking text and apostrophe-containing text in sync and
async regression tests

## Root cause
`Table.update` was the remaining Python write path that required callers
to construct a raw SQL predicate. Dynamic text interpolated without SQL
literal encoding could therefore be parsed as an integer, float, or
unterminated string instead of Utf8. The expression API already encodes
literals safely for query and delete filters.

## Validation
- `cd python && .venv/bin/pytest
python/tests/test_table.py::test_update_async
python/tests/test_table.py::test_update_expr_filter_literals_async
python/tests/test_table.py::test_update
python/tests/test_table.py::test_update_expr_filter_literals -q`
- `cd python && .venv/bin/pytest python/tests/test_expr.py -q`
- `cd python && .venv/bin/ruff format --check .`
- `cd python && .venv/bin/ruff check .`

Fixes #1869

<!-- lance-gatekeeper-fix:v1 agent=01f1e7b69c65e8b6d3b3c1e1a7918179
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 20:28:21 +08:00
lancedb-gatefixer[bot]andXuanwo 0dd9dfdfc7 test(python): cover arithmetic with distance projections (#3862)
## Summary

- add Python regression coverage for integer and double arithmetic
against the generated _distance column
- merge the current main base containing Lance v11.0.0-beta.3 from #3896
- verify both expressions retain the generated scoring field Float32
type and compute the expected values

## Root cause

Lance parsed dynamic projection expressions before vector search added
its generated Float32 _distance field. Without a typed provisional
field, expression discovery rejected mixed numeric arithmetic. Lance
upstream fixed discovery and final-schema replanning in
lance-format/lance#8163, and the current base consumes that fix through
Lance v11.0.0-beta.3.

## Validation

- uv run --extra tests pytest
python/tests/test_query.py::test_select_arithmetic_with_distance -vv
--maxfail=2 — 2 passed
- python/.venv/bin/ruff format --check python/python/tests/test_query.py
- python/.venv/bin/ruff check .

Fixes #2618

<!-- lance-gatekeeper-fix:v1 agent=816a060090517471edfb73652bb5c9fe
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 17:10:51 +08:00
lancedb-gatefixer[bot]andXuanwo d24b2dcacc fix: show nested fields in query schema errors (#3849)
## Summary

- enrich local query field-not-found errors with recursively qualified
Arrow struct leaf paths
- preserve all other Lance and DataFusion errors unchanged
- add a regression test for the Python-visible filter error described in
the issue

## Root cause

DataFusion builds `FieldNotFound` candidates from the top-level Arrow
schema even though Lance supports dotted struct-field filters. As a
result, the error listed only the struct container and hid its valid
nested leaves.

## Validation

- `cargo test --quiet --features remote -p lancedb
table::query::tests::test_missing_filter_field_lists_nested_fields --
--exact`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples` (passes
with pre-existing unrelated warnings)
- `cargo fmt --all -- --check`

Fixes #951

<!-- lance-gatekeeper-fix:v1 agent=7893f7a181fd8bc1ad00acc62d1a85c2
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 15:47:18 +08:00
lancedb-gatefixer[bot]andXuanwo 2deccf21cf fix(node): read Python embedding metadata (#3836)
## Summary

- normalize Python snake_case and TypeScript camelCase embedding
metadata
- use the normalized metadata for schema validation and embedding lookup
- cover appending through `Table.add()` with a Python-authored schema
fixture

## Root cause

Python writes embedding source and vector column names as
`source_column` and `vector_column`, but the TypeScript SDK only read
`sourceColumn` and `vectorColumn`. The missing source name reached the
add path as `undefined`, preventing JavaScript rows from being embedded
and appended.

## Validation

- `pnpm lint`
- `pnpm test __test__/embedding.test.ts __test__/arrow.test.ts
__test__/registry.test.ts --runInBand` (201 passed, 1 skipped)
- `pnpm build`
- `pnpm run docs`

Fixes #1289

<!-- lance-gatekeeper-fix:v1 agent=b71c18a5e33d26f4d138972e91d34e66
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 13:42:30 +08:00
Lance Release ead4d27bfc Bump version: 0.38.0-beta.10 → 0.38.0-beta.11 2026-08-27 04:31:57 +00:00
lancedb-gatefixer[bot] 5153e5a023 fix(node): preserve JSON field metadata when adding data (#4064)
## Summary

- preserve Arrow field metadata when matching record data to a provided
schema
- retain metadata on partially reconstructed nested struct fields
- add a regression test for lance.json metadata through Arrow IPC
serialization

## Root cause

The TypeScript schema inferrer rebuilt fields selected from a provided
schema without copying their metadata. JSON columns therefore kept their
LargeBinary physical type but lost the lance.json extension marker
before insert, causing the schema mismatch reported in the issue.

## Validation

- pnpm lint
- pnpm build
- pnpm tsc
- pnpm run docs
- pnpm test --runInBand (18 suites and 798 tests passed; 5 tests
skipped)

Fixes #4062

<!-- lance-gatekeeper-fix:v1 agent=3ec52632b71563f53d199b22629f8c4f
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
2026-08-26 16:18:57 -07:00
lancedb-gatefixer[bot]andXuanwo 79f626b09e fix: support double-quoted filter identifiers (#3825)
## Summary

- tokenize predicates with the same GenericDialect lexical rules Lance
delegates to
- rewrite only SQL-standard double-quoted identifier tokens to Lance
backticks
- apply one predicate contract to query, count, update, delete, and both
merge conditions
- cover mixed-case identifiers, ordinary literals, comments, and every
filter-bearing table operation

## Root cause

Lance plans double-quoted tokens as string literals for compatibility.
As a result, `"PartyAbbrev" = 'D'` compared two literals and silently
evaluated to false instead of filtering the mixed-case column.

## Validation

- `cargo fmt --all -- --check`
- `cargo test --locked --quiet --features remote -p lancedb
expr::sql::tests`
- `cargo test --locked --quiet --features remote -p lancedb
test_double_quoted_predicates_across_table_operations`
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`

Fixes #2057

<!-- lance-gatekeeper-fix:v1 agent=a44b6567cfd8890abb4f7395ff71971a
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 06:17:28 +08:00
lancedb-gatefixer[bot]andXuanwo ae81d73563 fix: share scans across batched vector queries (#3805)
<!-- lance-gatekeeper-fix:v1 agent=d30696bc46eb32f04c9927b0792e35d3
generation=1 -->

## Summary

- use the Lance native batch KNN path so fixed-size batch vector
searches share one flat table scan
- validate consistent query-vector dimensions and retain the per-vector
plan when offsets require its existing semantics
- add Rust and Python regressions and update Rust, Python, and
TypeScript API documentation

## Root cause

LanceDB expanded every vector in a batch into a separate scan plan and
joined the plans with `UnionExec`. For unindexed tables on S3, a batch
of ten vectors therefore ran ten concurrent full scans, amplifying CPU
and retained data enough to produce the reported memory spike.

The native Lance batch KNN path performs bounded-memory selection for
all query vectors over one flat scan. LanceDB now supplies the vectors
as a batch and avoids applying a global scanner limit to the combined
per-query results. Batch queries with a nonzero offset keep the previous
plan because the native batch API does not support per-query offsets.

## Validation

- targeted Rust batch-query plan and execution tests
- `cargo check --quiet --features remote --tests --examples`
- `cargo clippy --quiet --features remote --tests --examples`
- `cargo fmt --all -- --check`
- targeted Python batch-vector regression after rebuilding the extension
- Ruff formatting/checks for the touched Python files
- Node.js build, lint, docs generation, and targeted batch-vector Jest
test
- `git diff --check`

Fixes #2468

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-27 04:23:28 +08:00
Dan Tasse 8b7e13b0c6 docs: add comments about metadata conventions (#4054)
In LanceDB Enterprise, we've adopted these conventions to give some
"canonical" metadata paths. This lets us display them in a certain way
in the UI or let agents standardize on them, to assume they'll find info
in a certain place. This PR (only comments/docs) just documents those
choices.
2026-08-26 14:19:44 -04:00
Xuanwo b78f2a5044 feat: expose list-element FTS document granularity (#4050)
## Summary

LanceDB could not request Lance's list-element FTS document granularity
through Python or Remote APIs, and generic nested-field resolution
exposed Arrow's internal `item` segment instead of the public field
path.

This exposes typed `row | list_element` configuration for Python FTS
index creation and match/phrase queries, preserves `_doc_index`, and
keeps nested FTS paths public (for example, `docs.content`). Remote
list-element requests require server API version 0.6.0 so older servers
cannot silently execute them with row semantics; explicit row requests
remain compatible.

## Compatibility

Omitted index and query parameters retain row granularity. Remote row
index creation omits the new wire field.

## Tracking


[ENT-2342](https://linear.app/lancedb/issue/ENT-2342/expose-list-element-fts-document-granularity-end-to-end)
2026-08-26 23:27:39 +08:00
Wyatt Alt 06872463cf feat: declare conda environments on Functions (#4057)
A Function's remote environment can now be conda instead of pip.
`@udf(conda=[...], conda_channels=[...])` registers one; pip and conda
are exclusive, channels are priority-ordered and require conda. The Rust
and Python `PythonEnvironmentSpec` models gain `channels`, dropped from
the canonical JSON when empty so existing pip registrations keep their
digests.
2026-08-26 06:29:43 -07:00
lancedb-gatefixer[bot]andXuanwo 2fbf6d6211 test(python): cover concurrent S3 table opens (#3833)
## Summary

- add regression coverage for the reported synchronous Python workload
with 32 simultaneous `open_table` calls
- verify every independently opened S3-backed table handle can read
through the connection's shared session and object-store client

## Root cause

In Python v0.13.0, each synchronous table handle lazily constructed its
own Lance dataset. Opening many handles in parallel therefore triggered
independent S3 client construction and bucket-region resolution, which
failed under thread pressure. The current Rust-backed connection path
owns a shared Lance session and retains its object-store handle, so
table opens reuse the existing S3 client; these tests lock in that
behavior through the public Python API and a causal Session-registry
invariant.

## Validation

- `uvx --from 'ruff==0.15.20' ruff format --check
python/tests/test_s3.py`
- `uvx --from 'ruff==0.15.20' ruff check .`
- `cargo fmt --all`
- `cargo test --quiet --features remote -p lancedb
test_concurrent_open_table_reuses_connection_object_store`
- `cargo check --quiet --features remote --tests --examples`
- equivalent 32-thread `open_table(...).count_rows()` workload against a
local database
- targeted S3 test collected successfully locally; execution requires
the CI LocalStack service, which is unavailable in this runner

Fixes #1786

<!-- lance-gatekeeper-fix:v1 agent=d311f3c7151f77ae22b4997702e7b7db
generation=1 -->

---------

Co-authored-by: Gatefixer <313497061+lancedb-gatefixer[bot]@users.noreply.github.com>
Co-authored-by: Xuanwo <github@xuanwo.io>
2026-08-26 14:56:57 +08:00
159 changed files with 17956 additions and 13442 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
[tool.bumpversion] [tool.bumpversion]
current_version = "0.38.0-beta.10" current_version = "0.39.0-beta.4"
parse = """(?x) parse = """(?x)
(?P<major>0|[1-9]\\d*)\\. (?P<major>0|[1-9]\\d*)\\.
(?P<minor>0|[1-9]\\d*)\\. (?P<minor>0|[1-9]\\d*)\\.
+24
View File
@@ -44,3 +44,27 @@ updates:
python-deps: python-deps:
patterns: 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:
- "*"
+10 -4
View File
@@ -29,12 +29,14 @@ jobs:
steps: steps:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
node-version: "18" node-version: "24"
- uses: pnpm/action-setup@v6
with:
version: 11.1.1
# These rules are disabled because Github will always ensure there # These rules are disabled because Github will always ensure there
# is a blank line between the title and the body and Github will # is a blank line between the title and the body and Github will
# word wrap the description field to ensure a reasonable max line # word wrap the description field to ensure a reasonable max line
# length. # length.
- run: npm install @commitlint/config-conventional
- run: > - run: >
echo 'module.exports = { echo 'module.exports = {
"rules": { "rules": {
@@ -43,7 +45,11 @@ jobs:
"body-leading-blank": [0, "always"] "body-leading-blank": [0, "always"]
} }
}' > .commitlintrc.js }' > .commitlintrc.js
- run: npx commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG - run: >
pnpm dlx
--package @commitlint/cli@21.2.2
--package @commitlint/config-conventional@21.2.2
commitlint --extends @commitlint/config-conventional --verbose <<< $COMMIT_MSG
env: env:
COMMIT_MSG: > COMMIT_MSG: >
${{ github.event.pull_request.title }} ${{ github.event.pull_request.title }}
@@ -54,7 +60,7 @@ jobs:
with: with:
script: | script: |
const message = `**ACTION NEEDED** const message = `**ACTION NEEDED**
Lance follows the [Conventional Commits specification](https://www.conventionalcommits.org/en/v1.0.0/) for release automation. 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.\ The PR title and description are used as the merge commit message.\
+1 -1
View File
@@ -56,7 +56,7 @@ jobs:
uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0 uses: lycheeverse/lychee-action@e7477775783ea5526144ba13e8db5eec57747ce8 # v2.9.0
with: with:
# Restricted to http(s) on purpose. Much of docs/src is generated # Restricted to http(s) on purpose. Much of docs/src is generated
# API reference (the js/ tree comes from `npm run docs` in nodejs) # API reference (the js/ tree comes from `pnpm run docs` in nodejs)
# and the hand-written pages use mkdocstrings cross-references and # and the hand-written pages use mkdocstrings cross-references and
# nav-relative paths that only resolve in the site mkdocs builds, # nav-relative paths that only resolve in the site mkdocs builds,
# not in this checkout, so relative links would be reported as # not in this checkout, so relative links would be reported as
+1 -3
View File
@@ -55,9 +55,7 @@ jobs:
- name: Set up node - name: Set up node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 24
cache: 'npm'
cache-dependency-path: docs/package-lock.json
- name: Install node dependencies - name: Install node dependencies
working-directory: nodejs working-directory: nodejs
run: | run: |
+11 -13
View File
@@ -47,9 +47,8 @@ jobs:
version: 11.1.1 version: 11.1.1
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL # Build on a supported LTS; the matrix job below covers every
# in October. The library itself still supports Node >= 18 # Node version the library claims to support.
# (see test matrix below).
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: nodejs/pnpm-lock.yaml cache-dependency-path: nodejs/pnpm-lock.yaml
@@ -84,7 +83,7 @@ jobs:
timeout-minutes: 30 timeout-minutes: 30
strategy: strategy:
matrix: matrix:
node-version: [ "18", "20" ] node-version: [ "22", "24", "26" ]
runs-on: "ubuntu-22.04" runs-on: "ubuntu-22.04"
defaults: defaults:
run: run:
@@ -101,9 +100,9 @@ jobs:
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
name: Setup Node.js 24 for build name: Setup Node.js 24 for build
with: with:
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL # Build and install once on a fixed version so the generated docs
# in October. Build/install runs on Node 24; tests run on the # are identical across matrix legs; the tests below then run on each
# matrix version below using direct jest invocation. # supported Node version.
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: nodejs/pnpm-lock.yaml cache-dependency-path: nodejs/pnpm-lock.yaml
@@ -152,9 +151,9 @@ jobs:
S3_TEST: "1" S3_TEST: "1"
# Newer @smithy/core uses dynamic ESM imports. # Newer @smithy/core uses dynamic ESM imports.
NODE_OPTIONS: "--experimental-vm-modules" NODE_OPTIONS: "--experimental-vm-modules"
# Invoke jest directly because pnpm 11 itself requires Node 22+ # Invoke the installed jest binary directly; the pnpm shim is set up
# while the matrix tests on older Node versions. # against the build-phase Node, not the version selected above.
run: npx jest --verbose run: node_modules/.bin/jest --verbose
- name: Test examples - name: Test examples
working-directory: ./ working-directory: ./
env: env:
@@ -164,7 +163,7 @@ jobs:
run: | run: |
python ci/mock_openai.py & python ci/mock_openai.py &
cd nodejs/examples cd nodejs/examples
npx jest --testEnvironment jest-environment-node-single-context --verbose node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose
macos: macos:
timeout-minutes: 30 timeout-minutes: 30
# macos-15 ships a newer linker; the older macos-14 linker fails to insert # macos-15 ships a newer linker; the older macos-14 linker fails to insert
@@ -185,8 +184,7 @@ jobs:
version: 11.1.1 version: 11.1.1
- uses: actions/setup-node@v6 - uses: actions/setup-node@v6
with: with:
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL # pnpm 11 requires Node >= 22.13.
# in October.
node-version: 24 node-version: 24
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: nodejs/pnpm-lock.yaml cache-dependency-path: nodejs/pnpm-lock.yaml
+81 -36
View File
@@ -40,40 +40,31 @@ jobs:
- target: aarch64-apple-darwin - target: aarch64-apple-darwin
host: macos-latest host: macos-latest
features: fp16kernels features: fp16kernels
# Fat LTO was ~111 of this job's ~113 minutes.
lto: thin
codegen_units: 16
pre_build: |- pre_build: |-
brew install protobuf 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 - target: x86_64-pc-windows-msvc
host: windows-2025 host: windows-2025
features: "," features: ","
# The lower peak also keeps this on the standard 4-core runner.
lto: thin
codegen_units: 16
pre_build: |- pre_build: |-
choco install --no-progress protoc ninja nasm choco install --no-progress protoc ninja nasm
tail -n 1000 /c/ProgramData/chocolatey/logs/chocolatey.log tail -n 1000 /c/ProgramData/chocolatey/logs/chocolatey.log
# There is an issue where choco doesn't add nasm to the path # There is an issue where choco doesn't add nasm to the path
export PATH="$PATH:/c/Program Files/NASM" export PATH="$PATH:/c/Program Files/NASM"
nasm -v 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 - target: aarch64-pc-windows-msvc
host: windows-2025 host: windows-2025
features: "," features: ","
lto: thin
codegen_units: 16
pre_build: |- pre_build: |-
choco install --no-progress protoc choco install --no-progress protoc
rustup target add aarch64-pc-windows-msvc 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 - target: x86_64-unknown-linux-gnu
host: ubuntu-latest host: ubuntu-latest
features: fp16kernels features: fp16kernels
@@ -103,6 +94,14 @@ jobs:
# https://github.com/napi-rs/napi-rs/blob/main/debian-aarch64.Dockerfile # https://github.com/napi-rs/napi-rs/blob/main/debian-aarch64.Dockerfile
docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64 docker: ghcr.io/napi-rs/napi-rs/nodejs-rust:lts-debian-aarch64
features: "fp16kernels" 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: |- pre_build: |-
set -e && set -e &&
apt-get update && apt-get update &&
@@ -112,9 +111,30 @@ jobs:
# AT_HWCAP2 (added in Linux 3.17). Define it for aws-lc-sys. # AT_HWCAP2 (added in Linux 3.17). Define it for aws-lc-sys.
export CFLAGS="$CFLAGS -DAT_HWCAP2=26" && export CFLAGS="$CFLAGS -DAT_HWCAP2=26" &&
rustup target add aarch64-unknown-linux-gnu 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 - target: aarch64-unknown-linux-musl
host: ubuntu-2404-8x-x64 host: ubuntu-2404-8x-x64
features: "," 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: |- pre_build: |-
set -e && set -e &&
sudo apt-get update && sudo apt-get update &&
@@ -123,6 +143,19 @@ jobs:
export EXTRA_ARGS="-x" export EXTRA_ARGS="-x"
name: build - ${{ matrix.settings.target }} name: build - ${{ matrix.settings.target }}
runs-on: ${{ matrix.settings.host }} 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: defaults:
run: run:
working-directory: nodejs working-directory: nodejs
@@ -135,8 +168,7 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v6 uses: actions/setup-node@v6
with: with:
# pnpm 11 requires Node >= 22.13; use 24 since 22 hits EOL # pnpm 11 requires Node >= 22.13.
# in October.
node-version: 24 node-version: 24
cache: pnpm cache: pnpm
cache-dependency-path: nodejs/pnpm-lock.yaml cache-dependency-path: nodejs/pnpm-lock.yaml
@@ -169,19 +201,15 @@ jobs:
# creating ref). The nightly cadence also keeps entries inside # creating ref). The nightly cadence also keeps entries inside
# GitHub's 7-day eviction window, which a tag-only trigger would not. # GitHub's 7-day eviction window, which a tag-only trigger would not.
save-if: ${{ github.ref == 'refs/heads/main' }} save-if: ${{ github.ref == 'refs/heads/main' }}
# Docker builds can use rust-cache too. `target/` already lives on the # Docker builds can use rust-cache too: the workspace is bind-mounted, so
# host because the whole workspace is bind-mounted into the container, and # `target/` lives on the host and rust-cache's prune keeps the entry
# rust-cache's prune and save run host-side, so they can manage it -- which # small.
# 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 # Two differences from the native builds. The container's CARGO_HOME is
# bind-mounted from `.cargo-cache` rather than the host's ~/.cargo, so that # bind-mounted from `.cargo-cache` rather than ~/.cargo, so that is cached
# has to be cached explicitly. And the key is derived from the *host* rustc # explicitly. And the key uses the *host* rustc version, not the compiler
# version, which is not the compiler that produced these artifacts; that is # that built these artifacts -- safe, since cargo fingerprints the real
# safe because cargo fingerprints the real compiler and rebuilds on a # one; a base-image bump just costs one cold build.
# mismatch, it just means a base-image toolchain bump costs one cold build
# instead of invalidating the key.
- name: Cache cargo (docker builds) - name: Cache cargo (docker builds)
uses: Swatinem/rust-cache@v2 uses: Swatinem/rust-cache@v2
if: ${{ matrix.settings.docker }} if: ${{ matrix.settings.docker }}
@@ -210,14 +238,19 @@ jobs:
# cache step above saves. Previously the registry mounts pointed at # cache step above saves. Previously the registry mounts pointed at
# `.cargo/...`, a path nothing cached, so the container re-downloaded # `.cargo/...`, a path nothing cached, so the container re-downloaded
# the whole crate registry on every run. # 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 \ 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/cache:/usr/local/cargo/registry/cache \
-v ${{ github.workspace }}/.cargo-cache/registry/index:/usr/local/cargo/registry/index \ -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" -v ${{ github.workspace }}:/build -w /build/nodejs"
run: | run: |
set -e set -e
${{ matrix.settings.pre_build }} ${{ matrix.settings.pre_build }}
npx napi build --platform --release \ node_modules/.bin/napi build --platform --release \
--features ${{ matrix.settings.features }} \ --features ${{ matrix.settings.features }} \
--target ${{ matrix.settings.target }} \ --target ${{ matrix.settings.target }} \
--dts ../lancedb/native.d.ts \ --dts ../lancedb/native.d.ts \
@@ -237,7 +270,7 @@ jobs:
- name: Build - name: Build
run: | run: |
${{ matrix.settings.pre_build }} ${{ matrix.settings.pre_build }}
npx napi build --platform --release \ node_modules/.bin/napi build --platform --release \
--features ${{ matrix.settings.features }} \ --features ${{ matrix.settings.features }} \
--target ${{ matrix.settings.target }} \ --target ${{ matrix.settings.target }} \
--dts ../lancedb/native.d.ts \ --dts ../lancedb/native.d.ts \
@@ -256,6 +289,18 @@ jobs:
if: always() if: always()
run: df -h run: df -h
shell: bash 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 - name: Upload artifact
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
@@ -293,7 +338,7 @@ jobs:
- target: aarch64-unknown-linux-gnu - target: aarch64-unknown-linux-gnu
host: ubuntu-2404-8x-arm64 host: ubuntu-2404-8x-arm64
node: node:
- '20' - '22'
runs-on: ${{ matrix.settings.host }} runs-on: ${{ matrix.settings.host }}
defaults: defaults:
run: run:
@@ -339,9 +384,9 @@ jobs:
- name: Move built files - name: Move built files
run: cp dist/native.d.ts dist/native.js dist/*.node lancedb/ run: cp dist/native.d.ts dist/native.js dist/*.node lancedb/
- name: Test bindings - name: Test bindings
# Invoke jest directly because pnpm 11 itself requires Node 22+ # Invoke the installed jest binary directly; the pnpm shim is set up
# while the matrix tests on older Node versions. # against the install-phase Node, not the version selected above.
run: npx jest --verbose run: node_modules/.bin/jest --verbose
publish: publish:
name: Publish name: Publish
runs-on: ubuntu-latest runs-on: ubuntu-latest
+4 -1
View File
@@ -232,7 +232,10 @@ jobs:
ALL_FEATURES=`cargo metadata --format-version=1 --no-deps \ ALL_FEATURES=`cargo metadata --format-version=1 --no-deps \
| jq -r '.packages[] | .features | keys | .[]' \ | jq -r '.packages[] | .features | keys | .[]' \
| grep -v s3-test | sort | uniq | paste -s -d "," -` | grep -v s3-test | sort | uniq | paste -s -d "," -`
cargo test --profile ci --features $ALL_FEATURES --locked # Run doctests before test binaries fill the runner disk. Examples are
# already built by the Linux job, so avoid retaining them here.
cargo test --profile ci --features $ALL_FEATURES --locked --doc
cargo test --profile ci --features $ALL_FEATURES --locked --lib --tests
windows: windows:
strategy: strategy:
@@ -1,22 +0,0 @@
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 }}
@@ -1,22 +0,0 @@
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 }}
+4 -1
View File
@@ -20,7 +20,10 @@ repos:
hooks: hooks:
- id: local-biome-check - id: local-biome-check
name: biome check name: biome check
entry: npx @biomejs/biome@1.8.3 check --config-path nodejs/biome.json nodejs/ # 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/
language: system language: system
types: [text] types: [text]
files: "nodejs/.*" files: "nodejs/.*"
+3 -3
View File
@@ -38,7 +38,7 @@ Before committing changes, run formatting for every language you touched. At min
* Rust changes: run `cargo fmt --all`. * Rust changes: run `cargo fmt --all`.
* Python changes: run `ruff format .` and `ruff check .` from the repository root, * Python changes: run `ruff format .` and `ruff check .` from the repository root,
and run targeted tests through `cd python && uv run ...`. and run targeted tests through `cd python && uv run ...`.
* TypeScript changes: run the relevant `npm`/`pnpm` lint, format, build, and docs commands in `nodejs`. * TypeScript changes: run the relevant `pnpm` lint, format, build, and docs commands in `nodejs`.
Before creating a PR, the exact value passed to `gh pr create --title` must follow 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` Conventional Commits, such as `fix: support nested field paths in native index creation`
@@ -101,12 +101,12 @@ Python bindings changes:
TypeScript bindings changes: TypeScript bindings changes:
1. Add napi-rs method binding on `Table` in `nodejs/src/table.rs`. 1. Add napi-rs method binding on `Table` in `nodejs/src/table.rs`.
2. Run `npm run build` to generate TypeScript definitions. 2. Run `pnpm build` to generate TypeScript definitions.
3. Add typescript method on abstract class `Table` in `nodejs/src/table.ts`. 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`. 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. * Note: despite the name, this class is also used for remote tables.
5. Add test in `nodejs/__test__/table.test.ts`. 5. Add test in `nodejs/__test__/table.test.ts`.
6. Run `npm run docs` to generate TypeScript documentation. 6. Run `pnpm run docs` to generate TypeScript documentation.
## Python API reference ## Python API reference
Generated
+166 -76
View File
@@ -332,6 +332,34 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "arrow-flight"
version = "58.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2dbe34824c639e43136af8f106992792ab456540d54b880bc320a3192502d2e"
dependencies = [
"arrow-arith",
"arrow-array",
"arrow-buffer",
"arrow-cast",
"arrow-data",
"arrow-ipc",
"arrow-ord",
"arrow-row",
"arrow-schema",
"arrow-select",
"arrow-string",
"base64 0.22.1",
"bytes",
"futures",
"once_cell",
"paste",
"prost",
"prost-types",
"tonic",
"tonic-prost",
]
[[package]] [[package]]
name = "arrow-ipc" name = "arrow-ipc"
version = "58.4.0" version = "58.4.0"
@@ -535,9 +563,9 @@ dependencies = [
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.91" version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -1129,7 +1157,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum-core", "axum-core 0.4.5",
"bytes", "bytes",
"futures-util", "futures-util",
"http 1.5.0", "http 1.5.0",
@@ -1138,7 +1166,7 @@ dependencies = [
"hyper 1.9.0", "hyper 1.9.0",
"hyper-util", "hyper-util",
"itoa", "itoa",
"matchit", "matchit 0.7.3",
"memchr", "memchr",
"mime", "mime",
"percent-encoding", "percent-encoding",
@@ -1156,6 +1184,31 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core 0.5.6",
"bytes",
"futures-util",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"itoa",
"matchit 0.8.4",
"memchr",
"mime",
"percent-encoding",
"pin-project-lite",
"serde_core",
"sync_wrapper",
"tower",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "axum-core" name = "axum-core"
version = "0.4.5" version = "0.4.5"
@@ -1177,6 +1230,24 @@ dependencies = [
"tracing", "tracing",
] ]
[[package]]
name = "axum-core"
version = "0.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1"
dependencies = [
"bytes",
"futures-core",
"http 1.5.0",
"http-body 1.1.0",
"http-body-util",
"mime",
"pin-project-lite",
"sync_wrapper",
"tower-layer",
"tower-service",
]
[[package]] [[package]]
name = "backoff" name = "backoff"
version = "0.4.0" version = "0.4.0"
@@ -1443,9 +1514,9 @@ checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
[[package]] [[package]]
name = "bytemuck" name = "bytemuck"
version = "1.25.0" version = "1.25.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797"
dependencies = [ dependencies = [
"bytemuck_derive", "bytemuck_derive",
] ]
@@ -1597,9 +1668,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chacha20" name = "chacha20"
version = "0.10.0" version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
dependencies = [ dependencies = [
"cfg-if 1.0.4", "cfg-if 1.0.4",
"cpufeatures 0.3.0", "cpufeatures 0.3.0",
@@ -3455,8 +3526,8 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c"
[[package]] [[package]]
name = "fsst" name = "fsst"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"rand 0.9.5", "rand 0.9.5",
@@ -4815,8 +4886,8 @@ checksum = "e037a2e1d8d5fdbd49b16a4ea09d5d6401c1f29eca5ff29d03d3824dba16256a"
[[package]] [[package]]
name = "lance" name = "lance"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"arrow", "arrow",
@@ -4888,8 +4959,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-arrow" name = "lance-arrow"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -4911,7 +4982,7 @@ dependencies = [
[[package]] [[package]]
name = "lance-arrow-scalar" name = "lance-arrow-scalar"
version = "58.0.0" version = "58.0.0"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -4925,7 +4996,7 @@ dependencies = [
[[package]] [[package]]
name = "lance-arrow-stats" name = "lance-arrow-stats"
version = "58.0.0" version = "58.0.0"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-schema", "arrow-schema",
@@ -4934,8 +5005,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-bitpacking" name = "lance-bitpacking"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrayref", "arrayref",
"crunchy", "crunchy",
@@ -4945,8 +5016,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-core" name = "lance-core"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -4983,8 +5054,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-datafusion" name = "lance-datafusion"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow", "arrow",
"arrow-array", "arrow-array",
@@ -5000,6 +5071,7 @@ dependencies = [
"datafusion-functions", "datafusion-functions",
"datafusion-physical-expr", "datafusion-physical-expr",
"futures", "futures",
"half",
"jsonb", "jsonb",
"lance-arrow", "lance-arrow",
"lance-core", "lance-core",
@@ -5013,8 +5085,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-datagen" name = "lance-datagen"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow", "arrow",
"arrow-array", "arrow-array",
@@ -5031,8 +5103,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-derive" name = "lance-derive"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@@ -5041,8 +5113,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-encoding" name = "lance-encoding"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-arith", "arrow-arith",
"arrow-array", "arrow-array",
@@ -5075,8 +5147,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-file" name = "lance-file"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-arith", "arrow-arith",
"arrow-array", "arrow-array",
@@ -5107,8 +5179,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-index" name = "lance-index"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arc-swap", "arc-swap",
"arrow", "arrow",
@@ -5172,8 +5244,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-index-core" name = "lance-index-core"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-schema", "arrow-schema",
@@ -5195,8 +5267,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-io" name = "lance-io"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow", "arrow",
"arrow-array", "arrow-array",
@@ -5236,8 +5308,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-linalg" name = "lance-linalg"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-schema", "arrow-schema",
@@ -5251,27 +5323,29 @@ dependencies = [
[[package]] [[package]]
name = "lance-namespace" name = "lance-namespace"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow", "arrow",
"async-trait", "async-trait",
"bytes", "bytes",
"lance-core", "lance-core",
"lance-namespace-reqwest-client", "lance-namespace-reqwest-client",
"serde",
"serde_json",
"snafu 0.9.0", "snafu 0.9.0",
] ]
[[package]] [[package]]
name = "lance-namespace-impls" name = "lance-namespace-impls"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow", "arrow",
"arrow-ipc", "arrow-ipc",
"arrow-schema", "arrow-schema",
"async-trait", "async-trait",
"axum", "axum 0.7.9",
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"chrono", "chrono",
@@ -5304,9 +5378,9 @@ dependencies = [
[[package]] [[package]]
name = "lance-namespace-reqwest-client" name = "lance-namespace-reqwest-client"
version = "0.11.0" version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0a030196da1c994b63a96a4f0bf5b0cfa459fe6dadc9e962320246ca328da22a" checksum = "d8d23e54b1634d5bbb434f8dd33dc3c05f6e58d876a9a27b3b4aef58ddbe11af"
dependencies = [ dependencies = [
"reqwest 0.12.28", "reqwest 0.12.28",
"serde", "serde",
@@ -5318,8 +5392,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-select" name = "lance-select"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -5333,8 +5407,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-table" name = "lance-table"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow", "arrow",
"arrow-array", "arrow-array",
@@ -5374,8 +5448,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-testing" name = "lance-testing"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-schema", "arrow-schema",
@@ -5388,8 +5462,8 @@ dependencies = [
[[package]] [[package]]
name = "lance-tokenizer" name = "lance-tokenizer"
version = "12.0.0-beta.2" version = "12.0.0-beta.14"
source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.2#dafa4642658d996b3e31dde91e02f72db7860d7e" source = "git+https://github.com/lance-format/lance.git?tag=v12.0.0-beta.14#a8a101774a1c9647065cc60137094feadbe55296"
dependencies = [ dependencies = [
"frostem", "frostem",
"icu_segmenter", "icu_segmenter",
@@ -5402,7 +5476,7 @@ dependencies = [
[[package]] [[package]]
name = "lancedb" name = "lancedb"
version = "0.38.0-beta.10" version = "0.39.0-beta.4"
dependencies = [ dependencies = [
"ahash", "ahash",
"anyhow", "anyhow",
@@ -5411,6 +5485,7 @@ dependencies = [
"arrow-buffer", "arrow-buffer",
"arrow-cast", "arrow-cast",
"arrow-data", "arrow-data",
"arrow-flight",
"arrow-ipc", "arrow-ipc",
"arrow-ord", "arrow-ord",
"arrow-schema", "arrow-schema",
@@ -5466,6 +5541,7 @@ dependencies = [
"polars", "polars",
"polars-arrow", "polars-arrow",
"pprof 0.14.1", "pprof 0.14.1",
"prost",
"rand 0.9.5", "rand 0.9.5",
"random_word", "random_word",
"regex", "regex",
@@ -5482,6 +5558,7 @@ dependencies = [
"test-log", "test-log",
"tokenizers", "tokenizers",
"tokio", "tokio",
"tonic",
"url", "url",
"urlencoding", "urlencoding",
"uuid", "uuid",
@@ -5490,7 +5567,7 @@ dependencies = [
[[package]] [[package]]
name = "lancedb-nodejs" name = "lancedb-nodejs"
version = "0.38.0-beta.10" version = "0.39.0-beta.4"
dependencies = [ dependencies = [
"arrow-array", "arrow-array",
"arrow-buffer", "arrow-buffer",
@@ -5515,7 +5592,7 @@ dependencies = [
[[package]] [[package]]
name = "lancedb-python" name = "lancedb-python"
version = "0.38.0-beta.10" version = "0.39.0-beta.4"
dependencies = [ dependencies = [
"arrow", "arrow",
"async-trait", "async-trait",
@@ -5539,6 +5616,7 @@ dependencies = [
"serde_json", "serde_json",
"snafu 0.8.9", "snafu 0.8.9",
"tokio", "tokio",
"uuid",
] ]
[[package]] [[package]]
@@ -5748,9 +5826,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.33" version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]] [[package]]
name = "loom" name = "loom"
@@ -5859,6 +5937,12 @@ version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
[[package]]
name = "matchit"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3"
[[package]] [[package]]
name = "matrixmultiply" name = "matrixmultiply"
version = "0.3.10" version = "0.3.10"
@@ -6001,9 +6085,9 @@ dependencies = [
[[package]] [[package]]
name = "moka" name = "moka"
version = "0.12.15" version = "0.12.16"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "957228ad12042ee839f93c8f257b62b4c0ab5eaae1d4fa60de53b27c9d7c5046" checksum = "4293f18e7567a1caf3c584855554377025c65e0aa445344d04171f5ad63d19b9"
dependencies = [ dependencies = [
"async-lock", "async-lock",
"crossbeam-channel", "crossbeam-channel",
@@ -6097,14 +6181,15 @@ dependencies = [
[[package]] [[package]]
name = "napi" name = "napi"
version = "3.11.0" version = "3.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "de33522036981030a75c231829566bc63414e08101a6f5ff4ac6cef19c8e0941" checksum = "58c5f4d5375213fdb7be2655e152386e82f026f9a5ba36a75556e11359aafe09"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"chrono", "chrono",
"ctor 1.0.12", "ctor 1.0.12",
"futures", "futures",
"libc",
"napi-build", "napi-build",
"napi-sys", "napi-sys",
"nohash-hasher", "nohash-hasher",
@@ -6116,15 +6201,15 @@ dependencies = [
[[package]] [[package]]
name = "napi-build" name = "napi-build"
version = "2.4.0" version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5282704fbe8d49b0cf8b08e3f33233416a528658f205c7e5ace63b582de0b11c" checksum = "60fdf9b392c50e7c4170fa633bd909490ed7835cea4c046776d1a4dd8d2ae0ab"
[[package]] [[package]]
name = "napi-derive" name = "napi-derive"
version = "3.6.1" version = "3.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d5c9c02556ea6dc99dffd36c1ce60141411657438501a125b675776d011ce92" checksum = "0fa55ea69990c90b888e9e77044410e304ce7f35de599dc6d0b5c1923d2e59af"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"ctor 1.0.12", "ctor 1.0.12",
@@ -6136,9 +6221,9 @@ dependencies = [
[[package]] [[package]]
name = "napi-derive-backend" name = "napi-derive-backend"
version = "6.1.1" version = "6.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d60b5d773ad46c698c8cc2cd9fde0b283d39cbb7f71c04bee633c7bdba4423bd" checksum = "df4056ac7c18e4438ccf0edaed4340ca0d269278c8ec19284f7b23cb039fd0ae"
dependencies = [ dependencies = [
"convert_case", "convert_case",
"proc-macro2", "proc-macro2",
@@ -7733,6 +7818,7 @@ dependencies = [
"pyo3-build-config", "pyo3-build-config",
"pyo3-ffi", "pyo3-ffi",
"pyo3-macros", "pyo3-macros",
"uuid",
] ]
[[package]] [[package]]
@@ -8601,9 +8687,9 @@ dependencies = [
[[package]] [[package]]
name = "roaring" name = "roaring"
version = "0.11.4" version = "0.11.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1dedc5658c6ecb3bdb5ef5f3295bb9253f42dcf3fd1402c03f6b1f7659c3c4a9" checksum = "18bd8a37d17a58532776dcdf6041ce64929adca78e8489d5cacbafe99229d3e1"
dependencies = [ dependencies = [
"bytemuck", "bytemuck",
"byteorder", "byteorder",
@@ -9063,9 +9149,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with" name = "serde_with"
version = "3.21.0" version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"bs58", "bs58",
@@ -9073,6 +9159,7 @@ dependencies = [
"hex", "hex",
"indexmap 1.9.3", "indexmap 1.9.3",
"indexmap 2.14.0", "indexmap 2.14.0",
"jiff",
"schemars 0.9.0", "schemars 0.9.0",
"schemars 1.2.1", "schemars 1.2.1",
"serde_core", "serde_core",
@@ -9083,9 +9170,9 @@ dependencies = [
[[package]] [[package]]
name = "serde_with_macros" name = "serde_with_macros"
version = "3.21.0" version = "3.22.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46"
dependencies = [ dependencies = [
"darling 0.23.0", "darling 0.23.0",
"proc-macro2", "proc-macro2",
@@ -10084,6 +10171,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"axum 0.8.9",
"base64 0.22.1", "base64 0.22.1",
"bytes", "bytes",
"h2 0.4.16", "h2 0.4.16",
@@ -10095,9 +10183,11 @@ dependencies = [
"hyper-util", "hyper-util",
"percent-encoding", "percent-encoding",
"pin-project", "pin-project",
"rustls-native-certs",
"socket2 0.6.3", "socket2 0.6.3",
"sync_wrapper", "sync_wrapper",
"tokio", "tokio",
"tokio-rustls 0.26.4",
"tokio-stream", "tokio-stream",
"tower", "tower",
"tower-layer", "tower-layer",
@@ -10452,9 +10542,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.24.0" version = "1.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" checksum = "b5772d71c9be8a8a6ac2117d949c5b224c1b72241bb611d9a3012edcf8af7812"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
+17 -15
View File
@@ -13,20 +13,20 @@ categories = ["database-implementations"]
rust-version = "1.91.0" rust-version = "1.91.0"
[workspace.dependencies] [workspace.dependencies]
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 = { "version" = "=12.0.0-beta.14", default-features = false, "tag" = "v12.0.0-beta.14", "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-core = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-datagen = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-file = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-io = { "version" = "=12.0.0-beta.14", default-features = false, "tag" = "v12.0.0-beta.14", "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-index = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-linalg = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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 = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-namespace-impls = { "version" = "=12.0.0-beta.14", default-features = false, "tag" = "v12.0.0-beta.14", "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-table = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-testing = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-datafusion = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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-encoding = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "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" } lance-arrow = { "version" = "=12.0.0-beta.14", "tag" = "v12.0.0-beta.14", "git" = "https://github.com/lance-format/lance.git" }
lancedb = { path = "rust/lancedb", default-features = false } lancedb = { path = "rust/lancedb", default-features = false }
ahash = "0.8" ahash = "0.8"
# Note that this one does not include pyarrow # Note that this one does not include pyarrow
@@ -39,6 +39,7 @@ arrow-ord = "58.0.0"
arrow-schema = "58.0.0" arrow-schema = "58.0.0"
arrow-select = "58.0.0" arrow-select = "58.0.0"
arrow-cast = "58.0.0" arrow-cast = "58.0.0"
arrow-flight = { version = "58.0.0", features = ["flight-sql-experimental"] }
async-trait = "0" async-trait = "0"
bytes = "1" bytes = "1"
datafusion = { version = "54.0.0", default-features = false } datafusion = { version = "54.0.0", default-features = false }
@@ -71,7 +72,8 @@ serde = "1"
serde_json = "1" serde_json = "1"
tempfile = "3.5.0" tempfile = "3.5.0"
tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] } tokio = { version = "1.23", features = ["rt-multi-thread", "sync"] }
uuid = { version = "1.7.0", features = ["v4"] } tonic = { version = "0.14", features = ["tls-native-roots", "tls-ring"] }
uuid = { version = "1.7.0", features = ["v4", "v7"] }
chrono = { version = "0.4", default-features = false, features = ["clock"] } chrono = { version = "0.4", default-features = false, features = ["clock"] }
[profile.ci] [profile.ci]
+1 -1
View File
@@ -5,5 +5,5 @@ licenses:
cd python && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml cd python && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml
cd python && uv sync --all-extras && uv tool run pip-licenses --python .venv/bin/python --format=markdown --with-urls --output-file=PYTHON_THIRD_PARTY_LICENSES.md cd 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 && cargo about generate ../about.hbs -o RUST_THIRD_PARTY_LICENSES.html -c ../about.toml
cd nodejs && npx license-checker --markdown --out NODEJS_THIRD_PARTY_LICENSES.md cd nodejs && pnpm dlx license-checker@25 --markdown --out NODEJS_THIRD_PARTY_LICENSES.md
cd java && ./mvnw license:aggregate-add-third-party -q cd java && ./mvnw license:aggregate-add-third-party -q
+2 -6
View File
@@ -12,16 +12,12 @@ done
# This updates the lockfile without building # This updates the lockfile without building
cargo metadata --quiet > /dev/null cargo metadata --quiet > /dev/null
pushd nodejs || exit 1
npm install --package-lock-only --silent
popd
if git diff --quiet --exit-code; then if git diff --quiet --exit-code; then
echo "No lockfile changes to commit; skipping amend." echo "No lockfile changes to commit; skipping amend."
elif $AMEND; then elif $AMEND; then
git add Cargo.lock nodejs/package-lock.json git add Cargo.lock
git commit --amend --no-edit git commit --amend --no-edit
else else
git add Cargo.lock nodejs/package-lock.json git add Cargo.lock
git commit -m "Update lockfiles" git commit -m "Update lockfiles"
fi fi
+1 -11
View File
@@ -131,18 +131,13 @@ allow = [
"BSD-3-Clause", "BSD-3-Clause",
"ISC", "ISC",
"Unicode-3.0", "Unicode-3.0",
"Unicode-DFS-2016",
"Zlib", "Zlib",
"CC0-1.0", "CC0-1.0",
"MPL-2.0", "MPL-2.0",
"BSL-1.0", "BSL-1.0",
"OpenSSL",
# 0BSD ("BSD Zero Clause") is effectively public domain — no attribution # 0BSD ("BSD Zero Clause") is effectively public domain — no attribution
# required. Pulled in by `mock_instant`. # required. Pulled in by `mock_instant`.
"0BSD", "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` # CDLA-Permissive-2.0 is a permissive data license used by `webpki-roots`
# for the Mozilla CA root bundle. Data-only, distribution-compatible. # for the Mozilla CA root bundle. Data-only, distribution-compatible.
"CDLA-Permissive-2.0", "CDLA-Permissive-2.0",
@@ -150,12 +145,7 @@ allow = [
confidence-threshold = 0.8 confidence-threshold = 0.8
# Per-crate license exceptions: allow a license for a specific crate only, # Per-crate license exceptions: allow a license for a specific crate only,
# rather than globally via the `allow` list above. # 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 # Crates whose license cannot be determined from Cargo metadata but whose
# license we've manually confirmed from upstream. Keep this list minimal. # license we've manually confirmed from upstream. Keep this list minimal.
[[licenses.clarify]] [[licenses.clarify]]
+11 -8
View File
@@ -47,22 +47,24 @@ pytest -vv python/tests/docs
### Checking typescript examples ### Checking typescript examples
The `@lancedb/lancedb` package must be built before running the tests: 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.
```shell ```shell
pushd nodejs pushd nodejs
npm ci pnpm install
npm run build pnpm build
popd popd
``` ```
Then you can run the examples by going to the `nodejs/examples` directory and Then you can run the examples by going to the `nodejs/examples` directory, which is a
running the tests like a normal npm package: separate pnpm package with its own lockfile:
```shell ```shell
pushd nodejs/examples pushd nodejs/examples
npm ci pnpm install
npm test pnpm test
popd popd
``` ```
@@ -84,6 +86,7 @@ The new files should be checked into the repository.
```shell ```shell
pushd nodejs pushd nodejs
npm run docs # `pnpm docs` would invoke pnpm's built-in `docs` command, not the script.
pnpm run docs
popd popd
``` ```
+9
View File
@@ -446,6 +446,15 @@ paths:
properties: properties:
column: column:
type: string type: string
name:
type: string
description: Optional name for the created index.
replace:
type: boolean
default: true
description: |
Whether to replace an existing index with the same resolved
name. Defaults to true.
metric_type: metric_type:
type: string type: string
nullable: false nullable: false
-135
View File
@@ -1,135 +0,0 @@
{
"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
}
}
}
-20
View File
@@ -1,20 +0,0 @@
{
"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"
}
}
+1 -1
View File
@@ -14,7 +14,7 @@ Add the following dependency to your `pom.xml`:
<dependency> <dependency>
<groupId>com.lancedb</groupId> <groupId>com.lancedb</groupId>
<artifactId>lancedb-core</artifactId> <artifactId>lancedb-core</artifactId>
<version>0.38.0-beta.10</version> <version>0.39.0-beta.4</version>
</dependency> </dependency>
``` ```
+24 -62
View File
@@ -448,26 +448,6 @@ on the returned job to know when cleanup has finished.
*** ***
### getJob()
```ts
abstract getJob(jobId): Promise<null | JobDescription>
```
Describe a single server-side job by id.
Resolves to `null` when the server has no such job.
#### Parameters
* **jobId**: `string`
#### Returns
`Promise`&lt;`null` \| [`JobDescription`](../interfaces/JobDescription.md)&gt;
***
### isOpen() ### isOpen()
```ts ```ts
@@ -482,48 +462,6 @@ Return true if the connection has not been closed
*** ***
### job()
```ts
abstract job(jobId): Job
```
A [Job](Job.md) handle for a server-side job by id.
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect on
the job itself.
#### Parameters
* **jobId**: `string`
#### Returns
[`Job`](Job.md)
***
### jobHistory()
```ts
abstract jobHistory(jobId?): Promise<Table<any>>
```
The lifecycle event history of a server-side job, as an Arrow table.
Lists history across all jobs when `jobId` is omitted.
#### Parameters
* **jobId?**: `string`
#### Returns
`Promise`&lt;`Table`&lt;`any`&gt;&gt;
***
### listJobs() ### listJobs()
```ts ```ts
@@ -648,6 +586,30 @@ A page of table names and an
*** ***
### openJob()
```ts
abstract openJob(jobId): Promise<Job>
```
Open a server-side job by id, returning a handle with its record already
populated. Rejects when the server has no such job, the way
[Connection.openTable](Connection.md#opentable) does for a missing table.
The returned [Job](Job.md) answers for its own state, specification,
result, failure and event history, so there is no separate
connection-level call for any of them.
#### Parameters
* **jobId**: `string`
#### Returns
`Promise`&lt;[`Job`](Job.md)&gt;
***
### openMaterializedView() ### openMaterializedView()
```ts ```ts
+159 -12
View File
@@ -8,19 +8,46 @@
A handle to an operation that may still be running. A handle to an operation that may still be running.
## Constructors The operation may already be complete when the handle is created.
### new Job() The detail getters read what the handle last observed. Submitting an
operation returns only a job id, so populating them eagerly would cost an
extra round trip on every call:
- [Job.refresh](Job.md#refresh) and [Job.status](Job.md#status) fetch the whole record.
- [Job.wait](Job.md#wait) records the terminal state it establishes, but not the
rest of the record.
- Everything is null until one of those runs.
## Accessors
### creationMs
```ts ```ts
new Job(): Job get creationMs(): null | number
``` ```
When the job was created, in milliseconds since the epoch.
#### Returns #### Returns
[`Job`](Job.md) `null` \| `number`
## Accessors ***
### failure
```ts
get failure(): null | JobFailureInfo
```
Why the job failed, when it failed and the server reports a reason.
#### Returns
`null` \| [`JobFailureInfo`](../interfaces/JobFailureInfo.md)
***
### id ### id
@@ -28,8 +55,69 @@ new Job(): Job
get id(): null | string get id(): null | string
``` ```
Identifies the operation on the server that is running it. Operations Identifies the operation on the server that is running it.
that run in this process have no server id. The value is opaque.
Operations that run in this process have no server id. The value is
opaque: parsing it or storing it to resume the job later is not supported.
#### Returns
`null` \| `string`
***
### jobType
```ts
get jobType(): null | string
```
The job's type, as the server names it. Null for an in-process job, which
has no server-side record.
#### Returns
`null` \| `string`
***
### result
```ts
get result(): any
```
The job-type-specific terminal result. Null until the job succeeds, so a
job that never terminates reports its progress through [Job.events](Job.md#events)
instead.
#### Returns
`any`
***
### spec
```ts
get spec(): any
```
The job-type-specific specification it was submitted with.
#### Returns
`any`
***
### state
```ts
get state(): null | string
```
The last observed lifecycle state, without contacting the backend.
#### Returns #### Returns
@@ -51,18 +139,61 @@ Request cancellation. Cancelling a finished operation is a no-op.
*** ***
### events()
```ts
events(options?): Promise<Table<any>>
```
This job's recorded lifecycle events.
Where the getters above report a terminal result only once the job reaches
one, events are written as the job runs and outlive the workers that
produced them. A distributed job records a `claim`/`claim_complete` pair
per unit of work, each carrying `rows_processed`, so a job that never
finishes still accounts for what it did.
The server caps results at 1000 rows by default and 10,000 at most, and
truncates without saying so, so pass `limit` for a job that emits an event
per fragment. `filter` is a SQL-like expression over the `state`,
`updated_by`, `emitted_from`, `emitted_by`, and `claim_entity` columns.
#### Parameters
* **options?**: [`JobEventsOptions`](../interfaces/JobEventsOptions.md)
#### Returns
`Promise`&lt;`Table`&lt;`any`&gt;&gt;
***
### refresh()
```ts
refresh(): Promise<void>
```
Ask the backend for this job's current state, and for a server-side job
its full record, then cache it for the getters above.
#### Returns
`Promise`&lt;`void`&gt;
***
### status() ### status()
```ts ```ts
status(): Promise<string> status(): Promise<string>
``` ```
The operation's current lifecycle state: "running", "finished", The operation's current lifecycle state: "running", "finished", "failed",
"failed", or "cancelled". or "cancelled".
A point snapshot; unlike [Job.wait](Job.md#wait) it does not block or reject A point snapshot; unlike [Job.wait](Job.md#wait) it does not block or reject on a
on a terminal failure state. States a newer server reports that this terminal failure state. Also refreshes the getters above.
client version does not know pass through as-is.
#### Returns #### Returns
@@ -70,6 +201,22 @@ client version does not know pass through as-is.
*** ***
### toString()
```ts
toString(): string
```
Every field the handle currently knows, one per line, with the JSON
payloads indented -- a refresh job's spec and result are the point of
printing it.
#### Returns
`string`
***
### wait() ### wait()
```ts ```ts
+20
View File
@@ -676,9 +676,17 @@ List all the versions of the table
abstract mergeInsert(on): MergeInsertBuilder abstract mergeInsert(on): MergeInsertBuilder
``` ```
Create a [MergeInsertBuilder](MergeInsertBuilder.md), which combines new data with the
existing table in a single transaction — inserting, updating and deleting
rows depending on how they match.
#### Parameters #### Parameters
* **on**: `string` \| `string`[] * **on**: `string` \| `string`[]
The column, or columns, to match source rows against target
rows on. Typically a key or id column. Several columns match on the
composite key: a source row updates a target row only when it agrees on
every one of them.
#### Returns #### Returns
@@ -1292,6 +1300,18 @@ abstract updateFieldMetadata(updates): Promise<UpdateFieldMetadataResult>
Update per-field (column) metadata. Update per-field (column) metadata.
The following keys are treated specially, by convention, and should be
used when appropriate:
- `lancedb:description`: for a human-readable description of a field.
- `lancedb:tag:<name>`: for a user-defined key-value tag, where the suffix
names the tag category; e.g. `lancedb:tag:model: "clip"`.
- `lancedb:logical-column`: for a column grouping; e.g. `feature_v1` and
`feature_v2` might be in the same logical column.
- `lancedb:status`: for status options (`production`, `candidate`,
`deprecated`, `archived`) to designate the current life cycle state of
this column.
#### Parameters #### Parameters
* **updates**: [`FieldMetadataUpdate`](../interfaces/FieldMetadataUpdate.md)[] * **updates**: [`FieldMetadataUpdate`](../interfaces/FieldMetadataUpdate.md)[]
+1 -1
View File
@@ -96,7 +96,7 @@
- [IvfFlatOptions](interfaces/IvfFlatOptions.md) - [IvfFlatOptions](interfaces/IvfFlatOptions.md)
- [IvfPqOptions](interfaces/IvfPqOptions.md) - [IvfPqOptions](interfaces/IvfPqOptions.md)
- [IvfRqOptions](interfaces/IvfRqOptions.md) - [IvfRqOptions](interfaces/IvfRqOptions.md)
- [JobDescription](interfaces/JobDescription.md) - [JobEventsOptions](interfaces/JobEventsOptions.md)
- [JobFailureInfo](interfaces/JobFailureInfo.md) - [JobFailureInfo](interfaces/JobFailureInfo.md)
- [JobInfo](interfaces/JobInfo.md) - [JobInfo](interfaces/JobInfo.md)
- [ListNamespacesOptions](interfaces/ListNamespacesOptions.md) - [ListNamespacesOptions](interfaces/ListNamespacesOptions.md)
@@ -17,7 +17,8 @@ metadata: Record<string, null | string>;
``` ```
Metadata key/value pairs. Merged into the field's existing metadata by Metadata key/value pairs. Merged into the field's existing metadata by
default; a value of `null` deletes that key. default; a value of `null` deletes that key. See
[Table.updateFieldMetadata](../classes/Table.md#updatefieldmetadata) for the conventional `lancedb:*` keys.
*** ***
-66
View File
@@ -1,66 +0,0 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / JobDescription
# Interface: JobDescription
A described job from `Connection.getJob`.
## Properties
### creationMs
```ts
creationMs: number;
```
When the job was created, in milliseconds since the epoch.
***
### failure?
```ts
optional failure: JobFailureInfo;
```
Why the job failed, when the job is failed and the server reports a
reason.
***
### jobId
```ts
jobId: string;
```
***
### jobType
```ts
jobType: string;
```
***
### specJson?
```ts
optional specJson: string;
```
The job-type-specific specification as a JSON string, when present.
***
### state
```ts
state: string;
```
Lifecycle state: "running", "finished", "failed", or "cancelled".
@@ -0,0 +1,29 @@
[**@lancedb/lancedb**](../README.md) • **Docs**
***
[@lancedb/lancedb](../globals.md) / JobEventsOptions
# Interface: JobEventsOptions
Which of a job's events [Job.events](../classes/Job.md#events) returns.
## Properties
### filter?
```ts
optional filter: string;
```
SQL-like filter over the event columns.
***
### limit?
```ts
optional limit: number;
```
Maximum event rows to return, up to the server maximum of 10,000.
+1 -1
View File
@@ -26,7 +26,7 @@ When the job was created, in milliseconds since the epoch.
jobId: string; jobId: string;
``` ```
The job id -- what `Connection.getJob` and `Connection.cancelJob` The job id -- what `Connection.openJob` and `Connection.cancelJob`
accept. accept.
*** ***
@@ -50,6 +50,16 @@ projections: [string, string][];
*** ***
### sourceNamespace
```ts
sourceNamespace: string[];
```
Namespace holding the source table; empty is the root namespace.
***
### sourceTable ### sourceTable
```ts ```ts
+85 -2
View File
@@ -28,6 +28,59 @@ is also an [asynchronous API client](#connections-asynchronous).
::: lancedb.Session ::: lancedb.Session
## Remote SQL
Submit SQL against a remote LanceDB database through the connection.
The connected database and `default_namespace_path=["public"]` are used for
unqualified tables. Fully qualified references can still query other databases
and namespaces available to the same deployment. `execute_query` returns a
reader as soon as its initial result stream is available. `execute_query_async`
returns a query handle immediately; use it to inspect progress, open a reader,
or cancel the query. The SQL client is initialized by the first query and
retained for the lifetime of the remote connection. Query ids are random,
connection-scoped references rather than encoded SQL or durable resume tokens:
```python
import lancedb
db = lancedb.connect(
"db://analytics",
api_key="ldb_...",
host_override="https://api.example.com",
sql_host_override="grpc+tls://sql.example.com:10026",
)
reader = db.execute_query(
"""
SELECT events.id, accounts.name
FROM analytics.public.events AS events
JOIN users.public.accounts AS accounts ON events.user_id = accounts.id
""",
default_namespace_path=["public"],
)
for batch in reader:
print(batch.num_rows)
query = db.execute_query_async("SELECT * FROM events")
print(query.id)
print(query.describe().status)
for batch in query.reader():
print(batch.num_rows)
# The async connection exposes the same lifecycle without blocking:
# async_db = await lancedb.connect_async(
# "db://analytics",
# api_key="ldb_...",
# host_override="https://api.example.com",
# sql_host_override="grpc+tls://sql.example.com:10026",
# )
# reader = await async_db.execute_query("SELECT * FROM events")
# query = await async_db.execute_query_async("SELECT * FROM events")
# description = await async_db.describe_query(query.id)
# async for batch in await query.reader():
# print(batch.num_rows)
# await query.cancel()
```
## Namespaces (Synchronous) ## Namespaces (Synchronous)
A namespace-backed connection resolves tables through a A namespace-backed connection resolves tables through a
@@ -72,6 +125,10 @@ listing a storage directory.
::: lancedb.functions.UdfDefinition ::: lancedb.functions.UdfDefinition
::: lancedb.secrets.EnvVarSecret
::: lancedb.secrets.SecretInfo
::: lancedb.functions.FunctionRegistrationRequest ::: lancedb.functions.FunctionRegistrationRequest
::: lancedb.functions.FunctionArtifactRequest ::: lancedb.functions.FunctionArtifactRequest
@@ -94,6 +151,8 @@ listing a storage directory.
::: lancedb.functions.OutputMapping ::: lancedb.functions.OutputMapping
::: lancedb.functions.AssignmentMapping
::: lancedb.functions.FunctionBinding ::: lancedb.functions.FunctionBinding
::: lancedb.functions.RefreshColumnResult ::: lancedb.functions.RefreshColumnResult
@@ -102,6 +161,18 @@ listing a storage directory.
::: lancedb.job.AsyncJob ::: lancedb.job.AsyncJob
::: lancedb.job.JobInfo
::: lancedb.job.JobDescription
::: lancedb.job.JobFailureInfo
::: lancedb.sql.Query
::: lancedb.sql.AsyncQuery
::: lancedb.sql.QueryDescription
## Materialized Views (Synchronous) ## Materialized Views (Synchronous)
::: lancedb.materialized_view.MaterializedView ::: lancedb.materialized_view.MaterializedView
@@ -159,6 +230,8 @@ and combined with [BooleanQuery][lancedb.query.BooleanQuery].
::: lancedb.query.FullTextOperator ::: lancedb.query.FullTextOperator
::: lancedb.query.DocumentGranularity
::: lancedb.query.Occur ::: lancedb.query.Occur
## Embeddings ## Embeddings
@@ -221,9 +294,13 @@ tokens = list(
Blob columns store large binary values out of line so they can be read lazily Blob columns store large binary values out of line so they can be read lazily
instead of being materialized with the rest of the row. instead of being materialized with the rest of the row.
::: lancedb.blob `lancedb.BlobType` is `lance.blob.BlobType` when pylance is installed. Without
pylance, LanceDB uses a matching `lance.blob.v2` extension type so blob columns
still work. Queries return descriptors. Call
[`fetch_blob_files`][lancedb.table.Table.fetch_blob_files] for lazy reads or
[`fetch_blobs`][lancedb.table.Table.fetch_blobs] for eager bytes.
::: lancedb.BlobType ::: lancedb.blob
::: lancedb._blob.BlobFile ::: lancedb._blob.BlobFile
options: options:
@@ -243,6 +320,12 @@ instead of being materialized with the rest of the row.
::: lancedb.exceptions.MissingColumnError ::: lancedb.exceptions.MissingColumnError
::: lancedb.exceptions.JobNotFoundError
::: lancedb.exceptions.JobFailedError
::: lancedb.exceptions.JobCancelledError
## Integrations ## Integrations
## Pydantic ## Pydantic
-17
View File
@@ -1,17 +0,0 @@
{
"include": [
"src/*.ts",
],
"compilerOptions": {
"target": "es2022",
"module": "nodenext",
"declaration": true,
"outDir": "./dist",
"strict": true,
"allowJs": true,
"resolveJsonModule": true,
},
"exclude": [
"./dist/*",
]
}
+1 -1
View File
@@ -8,7 +8,7 @@
<parent> <parent>
<groupId>com.lancedb</groupId> <groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId> <artifactId>lancedb-parent</artifactId>
<version>0.38.0-beta.10</version> <version>0.39.0-beta.4</version>
<relativePath>../pom.xml</relativePath> <relativePath>../pom.xml</relativePath>
</parent> </parent>
+2 -2
View File
@@ -6,7 +6,7 @@
<groupId>com.lancedb</groupId> <groupId>com.lancedb</groupId>
<artifactId>lancedb-parent</artifactId> <artifactId>lancedb-parent</artifactId>
<version>0.38.0-beta.10</version> <version>0.39.0-beta.4</version>
<packaging>pom</packaging> <packaging>pom</packaging>
<name>${project.artifactId}</name> <name>${project.artifactId}</name>
<description>LanceDB Java SDK Parent POM</description> <description>LanceDB Java SDK Parent POM</description>
@@ -28,7 +28,7 @@
<properties> <properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<arrow.version>15.0.0</arrow.version> <arrow.version>15.0.0</arrow.version>
<lance-core.version>12.0.0-beta.2</lance-core.version> <lance-core.version>12.0.0-beta.14</lance-core.version>
<spotless.skip>false</spotless.skip> <spotless.skip>false</spotless.skip>
<spotless.version>2.30.0</spotless.version> <spotless.version>2.30.0</spotless.version>
<spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version> <spotless.java.googlejavaformat.version>1.7</spotless.java.googlejavaformat.version>
+1 -1
View File
@@ -1,7 +1,7 @@
[package] [package]
name = "lancedb-nodejs" name = "lancedb-nodejs"
edition.workspace = true edition.workspace = true
version = "0.38.0-beta.10" version = "0.39.0-beta.4"
publish = false publish = false
license.workspace = true license.workspace = true
description.workspace = true description.workspace = true
+58
View File
@@ -1,11 +1,16 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors // SPDX-FileCopyrightText: Copyright The LanceDB Authors
import * as fs from "node:fs";
import * as vm from "node:vm";
import * as arrow15 from "apache-arrow-15"; import * as arrow15 from "apache-arrow-15";
import * as arrow16 from "apache-arrow-16"; import * as arrow16 from "apache-arrow-16";
import * as arrow17 from "apache-arrow-17"; import * as arrow17 from "apache-arrow-17";
import * as arrow18 from "apache-arrow-18"; import * as arrow18 from "apache-arrow-18";
import { import {
Field as CurrentField,
LargeBinary as CurrentLargeBinary,
Schema as CurrentSchema,
Vector as CurrentVector, Vector as CurrentVector,
convertToTable, convertToTable,
tableFromIPC as currentTableFromIPC, tableFromIPC as currentTableFromIPC,
@@ -36,6 +41,59 @@ function sampleRecords(): Array<Record<string, any>> {
}, },
]; ];
} }
it("serializes an Arrow Table created in another JavaScript realm", async () => {
const context = vm.createContext({
TextDecoder,
TextEncoder,
console,
setTimeout,
clearTimeout,
});
vm.runInContext(
fs.readFileSync(
require.resolve("apache-arrow-15/Arrow.es2015.min"),
"utf8",
),
context,
);
const foreignTable: unknown = vm.runInContext(
"Arrow.tableFromArrays({ id: new Int32Array([1, 2, 3]), text: ['foo', 'bar', 'baz'] })",
context,
);
const foreignMetadata = (
foreignTable as { schema: { metadata: Map<string, string> } }
).schema.metadata;
expect(foreignMetadata).not.toBeInstanceOf(Map);
const buf = await fromDataToBuffer(
foreignTable as Parameters<typeof fromDataToBuffer>[0],
);
const actual = currentTableFromIPC(buf);
expect(actual.numRows).toBe(3);
expect(actual.getChild("id")?.toJSON()).toEqual([1, 2, 3]);
expect(actual.getChild("text")?.toJSON()).toEqual(["foo", "bar", "baz"]);
});
it("preserves field metadata from a provided schema", async function () {
const jsonMetadata = new Map([["ARROW:extension:name", "lance.json"]]);
const schema = new CurrentSchema([
new CurrentField("meta", new CurrentLargeBinary(), true, jsonMetadata),
]);
const table = makeArrowTable(
[{ meta: Buffer.from(JSON.stringify({ source: "test" })) }],
{ schema },
);
expect(table.schema.fields[0].metadata).toEqual(jsonMetadata);
const roundTripped = currentTableFromIPC(await fromTableToBuffer(table));
expect(roundTripped.schema.fields[0].metadata).toEqual(jsonMetadata);
});
describe.each([arrow15, arrow16, arrow17, arrow18])( describe.each([arrow15, arrow16, arrow17, arrow18])(
"Arrow", "Arrow",
( (
+52
View File
@@ -187,6 +187,58 @@ describe("embedding functions", () => {
const vector0 = JSON.parse(JSON.stringify(arr[0].vector)); const vector0 = JSON.parse(JSON.stringify(arr[0].vector));
expect(vector0).toEqual([1, 2, 3]); expect(vector0).toEqual([1, 2, 3]);
}); });
it("should append multiple Python embeddings with the same alias", async () => {
@register("python-mock")
// biome-ignore lint/correctness/noUnusedVariables: the decorator registers this class
class MockEmbeddingFunction extends EmbeddingFunction<string> {
ndims() {
return 3;
}
embeddingDataType(): Float {
return new Float32();
}
async computeQueryEmbeddings(_data: string) {
return [1, 2, 3];
}
async computeSourceEmbeddings(data: string[]) {
return data.map((value) =>
value === "hello world" ? [1, 2, 3] : [4, 5, 6],
);
}
}
const metadata = new Map([
[
"embedding_functions",
'[{"source_column":"text1","vector_column":"vector1","name":"python-mock","model":{}},{"source_column":"text2","vector_column":"vector2","name":"python-mock","model":{}}]',
],
]);
const schema = new Schema(
[
new Field("text1", new Utf8(), true),
new Field("text2", new Utf8(), true),
new Field(
"vector1",
new FixedSizeList(3, new Field("item", new Float32(), true)),
true,
),
new Field(
"vector2",
new FixedSizeList(3, new Field("item", new Float32(), true)),
true,
),
],
metadata,
);
const db = await connect(tmpDir.name);
const table = await db.createEmptyTable("test", schema);
await table.add([{ text1: "hello world", text2: "goodbye world" }]);
const rows = await table.query().toArray();
expect(JSON.parse(JSON.stringify(rows[0].vector1))).toEqual([1, 2, 3]);
expect(JSON.parse(JSON.stringify(rows[0].vector2))).toEqual([4, 5, 6]);
});
it("should append generated vectors to a non-nullable schema", async () => { it("should append generated vectors to a non-nullable schema", async () => {
@register("non_nullable_schema_test") @register("non_nullable_schema_test")
+22
View File
@@ -48,6 +48,28 @@ describe("materialized views", () => {
expect(definitionFromMetadata(safe, "v").limit).toBe(42); expect(definitionFromMetadata(safe, "v").limit).toBe(42);
}); });
it("reads the namespaced select kind and refuses unknown kinds", () => {
// "namespaced_select" is the namespaced form of "select": same shape, a
// separate kind so readers that predate it refuse instead of resolving
// the source at the root.
const namespaced = new Map([
[
DEFINITION_META_KEY,
'{"kind":"namespaced_select","source_table":"people","source_namespace":["ns"]}',
],
]);
const definition = definitionFromMetadata(namespaced, "v");
expect(definition.sourceTable).toBe("people");
expect(definition.sourceNamespace).toEqual(["ns"]);
const unknown = new Map([
[DEFINITION_META_KEY, '{"kind":"select_v3","source_table":"people"}'],
]);
expect(() => definitionFromMetadata(unknown, "v")).toThrow(
/cannot refresh/,
);
});
it("creates, refreshes and queries a view", async () => { it("creates, refreshes and queries a view", async () => {
const view = await db.createMaterializedView("adults", "people", { const view = await db.createMaterializedView("adults", "people", {
select: ["name", ["shout", "upper(name)"]], select: ["name", ["shout", "upper(name)"]],
+2 -2
View File
@@ -5,8 +5,8 @@ import packageJson = require("../package.json");
describe("package metadata", () => { describe("package metadata", () => {
it("requires Node.js type declarations compatible with the runtime", () => { it("requires Node.js type declarations compatible with the runtime", () => {
expect(packageJson.engines.node).toBe(">= 18"); expect(packageJson.engines.node).toBe(">= 22");
expect(packageJson.peerDependencies["@types/node"]).toBe(">=18"); expect(packageJson.peerDependencies["@types/node"]).toBe(">=22");
expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({ expect(packageJson.peerDependenciesMeta["@types/node"]).toEqual({
optional: true, optional: true,
}); });
+75 -13
View File
@@ -3,6 +3,7 @@
import * as http from "http"; import * as http from "http";
import { RequestListener } from "http"; import { RequestListener } from "http";
import packageJson = require("../package.json");
import { import {
ClientConfig, ClientConfig,
Connection, Connection,
@@ -70,7 +71,13 @@ async function withMockDatabase(
try { try {
await callback(db); await callback(db);
} finally { } finally {
server.close(); // `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());
});
} }
} }
@@ -131,7 +138,7 @@ describe("remote connection", () => {
(req, res) => { (req, res) => {
expect(req.headers["x-api-key"]).toEqual("fake"); expect(req.headers["x-api-key"]).toEqual("fake");
expect(req.headers["user-agent"]).toEqual( expect(req.headers["user-agent"]).toEqual(
`LanceDB-Node-Client/${process.env.npm_package_version}`, `LanceDB-Node-Client/${packageJson.version}`,
); );
const body = JSON.stringify({ tables: [] }); const body = JSON.stringify({ tables: [] });
@@ -932,6 +939,7 @@ describe("remote connection jobs surface", () => {
const { tableFromArrays, tableToIPC } = await import("apache-arrow"); const { tableFromArrays, tableToIPC } = await import("apache-arrow");
const eventsTable = tableFromArrays({ state: ["created", "succeeded"] }); const eventsTable = tableFromArrays({ state: ["created", "succeeded"] });
const eventsBody = Buffer.from(tableToIPC(eventsTable, "stream")); const eventsBody = Buffer.from(tableToIPC(eventsTable, "stream"));
const queryEventsPayloads: Record<string, unknown>[] = [];
await withMockDatabase( await withMockDatabase(
(req, res) => { (req, res) => {
@@ -960,6 +968,16 @@ describe("remote connection jobs surface", () => {
); );
} }
} else if (req.url === "/v1/jobs/describe") { } else if (req.url === "/v1/jobs/describe") {
if (payload["job_id"] === "job-2") {
res
.writeHead(200, { "Content-Type": "application/json" })
.end(
'{"job_id": "job-2", "job_type": "refresh_column", ' +
'"job_state": "DONE", "creation_ms": 2000, ' +
'"result": {"rows_assigned": 1000000}}',
);
return;
}
if (payload["job_id"] !== "job-1") { if (payload["job_id"] !== "job-1") {
res.writeHead(404).end("no such job"); res.writeHead(404).end("no such job");
return; return;
@@ -981,6 +999,7 @@ describe("remote connection jobs surface", () => {
.writeHead(200, { "Content-Type": "application/json" }) .writeHead(200, { "Content-Type": "application/json" })
.end('{"job_id": "job-1"}'); .end('{"job_id": "job-1"}');
} else if (req.url === "/v1/jobs/query_events") { } else if (req.url === "/v1/jobs/query_events") {
queryEventsPayloads.push(payload);
res res
.writeHead(200, { .writeHead(200, {
"Content-Type": "application/vnd.apache.arrow.stream", "Content-Type": "application/vnd.apache.arrow.stream",
@@ -997,22 +1016,65 @@ describe("remote connection jobs surface", () => {
expect(jobs[0].state).toEqual("running"); expect(jobs[0].state).toEqual("running");
expect(jobs[1].state).toEqual("finished"); expect(jobs[1].state).toEqual("finished");
const description = await db.getJob("job-1");
expect(description?.state).toEqual("failed");
expect(JSON.parse(description?.specJson ?? "")).toEqual({
column: "vec",
});
expect(description?.failure?.message).toEqual("worker died");
expect(await db.getJob("missing")).toBeNull();
expect(await db.cancelJob("job-1")).toBe(true); expect(await db.cancelJob("job-1")).toBe(true);
expect(await db.cancelJob("missing")).toBe(false); expect(await db.cancelJob("missing")).toBe(false);
const history = await db.jobHistory("job-1"); // Opening a job hands back a populated handle; a missing one rejects.
expect(history.numRows).toEqual(2); await expect(db.openJob("missing")).rejects.toThrow("not found");
const finished = await db.openJob("job-2");
expect(finished.state).toEqual("finished");
expect(finished.result).toEqual({
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
rows_assigned: 1000000,
});
const job = db.job("job-1"); const job = await db.openJob("job-1");
expect(job.id).toEqual("job-1"); expect(job.id).toEqual("job-1");
// openJob already populated the handle; refresh() re-reads it.
expect(job.state).toEqual("failed");
await job.refresh();
expect(job.state).toEqual("failed");
expect(job.jobType).toEqual("create_index");
expect(job.creationMs).toEqual(1000);
expect(job.spec).toEqual({ column: "vec" });
expect(job.result).toBeNull();
expect(job.failure?.message).toEqual("worker died");
// The handle reaches its own events, supplying its job id.
const jobEvents = await job.events({
limit: 500,
filter: "state = 'claim_complete'",
});
expect(jobEvents.numRows).toEqual(2);
expect(queryEventsPayloads.pop()).toEqual({
// biome-ignore lint/style/useNamingConvention: snake_case mandated by the server wire format
job_id: "job-1",
limit: 500,
filter: "state = 'claim_complete'",
});
// Printing lays every known field out on its own line, with the JSON
// payloads indented rather than crammed onto one line.
expect(`${job}`).toEqual(
[
"Job(",
' id="job-1",',
' state="failed",',
' jobType="create_index",',
" creationMs=1000,",
" spec={",
' "column": "vec"',
" },",
" failure={",
' "phase": "execute",',
' "message": "worker died",',
' "retryable": true',
" },",
")",
].join("\n"),
);
expect(await job.status()).toEqual("failed"); expect(await job.status()).toEqual("failed");
await expect(job.wait()).rejects.toThrow("worker died"); await expect(job.wait()).rejects.toThrow("worker died");
}, },
+55 -1
View File
@@ -737,11 +737,12 @@ it("should query documents with LangChain PDF metadata", async () => {
describe("merge insert", () => { describe("merge insert", () => {
let tmpDir: tmp.DirResult; let tmpDir: tmp.DirResult;
let conn: Connection;
let table: Table; let table: Table;
beforeEach(async () => { beforeEach(async () => {
tmpDir = tmp.dirSync({ unsafeCleanup: true }); tmpDir = tmp.dirSync({ unsafeCleanup: true });
const conn = await connect(tmpDir.name); conn = await connect(tmpDir.name);
table = await conn.createTable("some_table", [ table = await conn.createTable("some_table", [
{ a: 1, b: "a" }, { a: 1, b: "a" },
@@ -779,6 +780,38 @@ describe("merge insert", () => {
expect(result.map((row) => ({ ...row }))).toEqual(expected); expect(result.map((row) => ({ ...row }))).toEqual(expected);
}); });
test("upsert on a composite key", async () => {
const composite = await conn.createTable("composite", [
{ shard: "a", id: 1, val: "x" },
{ shard: "a", id: 2, val: "y" },
{ shard: "b", id: 1, val: "z" },
]);
// ("a", 1) matches an existing row and updates it. ("b", 2) agrees with an
// existing row on each key column separately but on neither pair, so it is
// an insert.
const mergeInsertRes = await composite
.mergeInsert(["shard", "id"])
.whenMatchedUpdateAll()
.whenNotMatchedInsertAll()
.execute([
{ shard: "a", id: 1, val: "X" },
{ shard: "b", id: 2, val: "W" },
]);
expect(mergeInsertRes.numUpdatedRows).toBe(1);
expect(mergeInsertRes.numInsertedRows).toBe(1);
const result = (await composite.toArrow())
.toArray()
.sort((a, b) => a.shard.localeCompare(b.shard) || a.id - b.id);
expect(result.map((row) => ({ ...row }))).toEqual([
{ shard: "a", id: 1, val: "X" },
{ shard: "a", id: 2, val: "y" },
{ shard: "b", id: 1, val: "z" },
{ shard: "b", id: 2, val: "W" },
]);
});
test("conditional update", async () => { test("conditional update", async () => {
const newData = [ const newData = [
{ a: 2, b: "x" }, { a: 2, b: "x" },
@@ -3561,6 +3594,27 @@ describe("when creating an empty table", () => {
expect((actualSchema.fields[1].type as Float64).precision).toBe(2); expect((actualSchema.fields[1].type as Float64).precision).toBe(2);
}); });
it("can add and query JSON data", async () => {
const schema = new Schema([
new Field("id", new Int32(), true),
new Field(
"meta",
new Utf8(),
true,
new Map([["ARROW:extension:name", "arrow.json"]]),
),
]);
const table = await con.createEmptyTable("json", schema);
const meta = JSON.stringify({ x: 1 });
await table.add([{ id: 1, meta }]);
const rows = await table.query().toArray();
expect(rows).toHaveLength(1);
expect(rows[0].id).toBe(1);
expect(rows[0].meta).toBe(meta);
});
it("can create an empty table from schema that specifies field types by name", async () => { it("can create an empty table from schema that specifies field types by name", async () => {
const schemaLike = { const schemaLike = {
fields: [ fields: [
+1 -1
View File
@@ -170,7 +170,7 @@ test("basic table examples", async () => {
// --8<-- [end:create_index] // --8<-- [end:create_index]
// --8<-- [start:delete_rows] // --8<-- [start:delete_rows]
await tbl.delete('item = "fizz"'); await tbl.delete("item = 'fizz'");
// --8<-- [end:delete_rows] // --8<-- [end:delete_rows]
// --8<-- [start:drop_table] // --8<-- [start:drop_table]
+2 -1
View File
@@ -8,7 +8,8 @@
"//1": "--experimental-vm-modules is needed to run jest with sentence-transformers", "//1": "--experimental-vm-modules is needed to run jest with sentence-transformers",
"//2": "--testEnvironment 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", "//3": "See: https://github.com/huggingface/transformers.js/issues/57",
"test": "node --experimental-vm-modules node_modules/.bin/jest --testEnvironment jest-environment-node-single-context --verbose", "//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",
"lint": "biome check *.ts && biome format *.ts", "lint": "biome check *.ts && biome format *.ts",
"lint-ci": "biome ci .", "lint-ci": "biome ci .",
"lint-fix": "biome check --write *.ts && pnpm format", "lint-fix": "biome check --write *.ts && pnpm format",
+2 -2
View File
@@ -72,8 +72,7 @@ export type FieldLike =
}; };
export type DataLike = export type DataLike =
// biome-ignore lint/suspicious/noExplicitAny: <explanation> | import("apache-arrow").Data
| import("apache-arrow").Data<Struct<any>>
| { | {
// biome-ignore lint/suspicious/noExplicitAny: <explanation> // biome-ignore lint/suspicious/noExplicitAny: <explanation>
type: any; type: any;
@@ -82,6 +81,7 @@ export type DataLike =
stride: number; stride: number;
nullable: boolean; nullable: boolean;
children: DataLike[]; children: DataLike[];
dictionary?: { data: readonly DataLike[] };
get nullCount(): number; get nullCount(): number;
// biome-ignore lint/suspicious/noExplicitAny: <explanation> // biome-ignore lint/suspicious/noExplicitAny: <explanation>
values: Buffers<any>[BufferType.DATA]; values: Buffers<any>[BufferType.DATA];
+11 -37
View File
@@ -1,7 +1,6 @@
// SPDX-License-Identifier: Apache-2.0 // SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors // SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { tableFromIPC } from "apache-arrow";
import { import {
Data, Data,
SchemaLike, SchemaLike,
@@ -16,6 +15,7 @@ import {
makeEmptyTable, makeEmptyTable,
} from "./arrow"; } from "./arrow";
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry"; import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
import { Job } from "./job";
import { import {
MaterializedView, MaterializedView,
MaterializedViewSelect, MaterializedViewSelect,
@@ -27,8 +27,6 @@ import type {
CreateNamespaceResponse, CreateNamespaceResponse,
DescribeNamespaceResponse, DescribeNamespaceResponse,
DropNamespaceResponse, DropNamespaceResponse,
Job,
JobDescription,
JobInfo, JobInfo,
ListNamespacesResponse, ListNamespacesResponse,
ListTablesResponse, ListTablesResponse,
@@ -557,24 +555,19 @@ export abstract class Connection {
): Promise<void>; ): Promise<void>;
/** /**
* A {@link Job} handle for a server-side job by id. * Open a server-side job by id, returning a handle with its record already
* populated. Rejects when the server has no such job, the way
* {@link Connection.openTable} does for a missing table.
* *
* The handle is constructed without a server round trip; an unknown id * The returned {@link Job} answers for its own state, specification,
* surfaces when the handle is used. Dropping the handle has no effect on * result, failure and event history, so there is no separate
* the job itself. * connection-level call for any of them.
*/ */
abstract job(jobId: string): Job; abstract openJob(jobId: string): Promise<Job>;
/** List server-side jobs across the database's tables. */ /** List server-side jobs across the database's tables. */
abstract listJobs(): Promise<JobInfo[]>; abstract listJobs(): Promise<JobInfo[]>;
/**
* Describe a single server-side job by id.
*
* Resolves to `null` when the server has no such job.
*/
abstract getJob(jobId: string): Promise<JobDescription | null>;
/** /**
* Request cancellation of a server-side job by id. * Request cancellation of a server-side job by id.
* *
@@ -582,13 +575,6 @@ export abstract class Connection {
* such job exists. Cancelling an already-terminal job is a no-op success. * such job exists. Cancelling an already-terminal job is a no-op success.
*/ */
abstract cancelJob(jobId: string): Promise<boolean>; abstract cancelJob(jobId: string): Promise<boolean>;
/**
* The lifecycle event history of a server-side job, as an Arrow table.
*
* Lists history across all jobs when `jobId` is omitted.
*/
abstract jobHistory(jobId?: string): Promise<ArrowTable>;
} }
/** @hideconstructor */ /** @hideconstructor */
@@ -869,7 +855,7 @@ export class LocalConnection extends Connection {
} }
async dropTableAsync(name: string, namespacePath?: string[]): Promise<Job> { async dropTableAsync(name: string, namespacePath?: string[]): Promise<Job> {
return this.inner.dropTableAsync(name, namespacePath ?? []); return new Job(await this.inner.dropTableAsync(name, namespacePath ?? []));
} }
async dropAllTables(namespacePath?: string[]): Promise<void> { async dropAllTables(namespacePath?: string[]): Promise<void> {
@@ -928,29 +914,17 @@ export class LocalConnection extends Connection {
); );
} }
job(jobId: string): Job { async openJob(jobId: string): Promise<Job> {
return this.inner.job(jobId); return new Job(await this.inner.openJob(jobId));
} }
async listJobs(): Promise<JobInfo[]> { async listJobs(): Promise<JobInfo[]> {
return this.inner.listJobs(); return this.inner.listJobs();
} }
async getJob(jobId: string): Promise<JobDescription | null> {
return this.inner.getJob(jobId);
}
async cancelJob(jobId: string): Promise<boolean> { async cancelJob(jobId: string): Promise<boolean> {
return this.inner.cancelJob(jobId); return this.inner.cancelJob(jobId);
} }
async jobHistory(jobId?: string): Promise<ArrowTable> {
const buf = await this.inner.jobHistory(jobId);
if (buf.length === 0) {
return new ArrowTable();
}
return tableFromIPC(buf);
}
} }
/** /**
+3 -7
View File
@@ -94,13 +94,9 @@ export {
RenameTableOptions, RenameTableOptions,
} from "./connection"; } from "./connection";
export { export { JobFailureInfo, JobInfo, Session } from "./native.js";
Job,
JobDescription, export { Job, JobEventsOptions } from "./job";
JobFailureInfo,
JobInfo,
Session,
} from "./native.js";
export { export {
AutoQuery, AutoQuery,
+188
View File
@@ -0,0 +1,188 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
import { Table as ArrowTable, tableFromIPC } from "apache-arrow";
import { JobFailureInfo, Job as NativeJob } from "./native";
/** Which of a job's events {@link Job.events} returns. */
export interface JobEventsOptions {
/** Maximum event rows to return, up to the server maximum of 10,000. */
limit?: number;
/** SQL-like filter over the event columns. */
filter?: string;
}
/**
* A handle to an operation that may still be running.
*
* The operation may already be complete when the handle is created.
*
* The detail getters read what the handle last observed. Submitting an
* operation returns only a job id, so populating them eagerly would cost an
* extra round trip on every call:
*
* - {@link Job.refresh} and {@link Job.status} fetch the whole record.
* - {@link Job.wait} records the terminal state it establishes, but not the
* rest of the record.
* - Everything is null until one of those runs.
*
* @hideconstructor
*/
export class Job {
private readonly inner: NativeJob;
constructor(inner: NativeJob) {
this.inner = inner;
}
/**
* Identifies the operation on the server that is running it.
*
* Operations that run in this process have no server id. The value is
* opaque: parsing it or storing it to resume the job later is not supported.
*/
get id(): string | null {
return this.inner.id ?? null;
}
/** The last observed lifecycle state, without contacting the backend. */
get state(): string | null {
return this.inner.state ?? null;
}
/**
* The job's type, as the server names it. Null for an in-process job, which
* has no server-side record.
*/
get jobType(): string | null {
return this.inner.jobType ?? null;
}
/** When the job was created, in milliseconds since the epoch. */
get creationMs(): number | null {
return this.inner.creationMs ?? null;
}
/** The job-type-specific specification it was submitted with. */
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
get spec(): any | null {
return parseJson(this.inner.specJson);
}
/**
* The job-type-specific terminal result. Null until the job succeeds, so a
* job that never terminates reports its progress through {@link Job.events}
* instead.
*/
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
get result(): any | null {
return parseJson(this.inner.resultJson);
}
/** Why the job failed, when it failed and the server reports a reason. */
get failure(): JobFailureInfo | null {
return this.inner.failure ?? null;
}
/**
* The operation's current lifecycle state: "running", "finished", "failed",
* or "cancelled".
*
* A point snapshot; unlike {@link Job.wait} it does not block or reject on a
* terminal failure state. Also refreshes the getters above.
*/
async status(): Promise<string> {
return this.inner.status();
}
/** Wait until the operation reaches a terminal state. */
async wait(): Promise<void> {
return this.inner.wait();
}
/** Request cancellation. Cancelling a finished operation is a no-op. */
async cancel(): Promise<void> {
return this.inner.cancel();
}
/**
* Ask the backend for this job's current state, and for a server-side job
* its full record, then cache it for the getters above.
*/
async refresh(): Promise<void> {
return this.inner.refresh();
}
/**
* This job's recorded lifecycle events.
*
* Where the getters above report a terminal result only once the job reaches
* one, events are written as the job runs and outlive the workers that
* produced them. A distributed job records a `claim`/`claim_complete` pair
* per unit of work, each carrying `rows_processed`, so a job that never
* finishes still accounts for what it did.
*
* The server caps results at 1000 rows by default and 10,000 at most, and
* truncates without saying so, so pass `limit` for a job that emits an event
* per fragment. `filter` is a SQL-like expression over the `state`,
* `updated_by`, `emitted_from`, `emitted_by`, and `claim_entity` columns.
*/
async events(options?: JobEventsOptions): Promise<ArrowTable> {
const buf = await this.inner.events(options?.limit, options?.filter);
if (buf.length === 0) {
return new ArrowTable();
}
return tableFromIPC(buf);
}
/**
* Every field the handle currently knows, one per line, with the JSON
* payloads indented -- a refresh job's spec and result are the point of
* printing it.
*/
toString(): string {
if (this.state === null) {
const known = this.id === null ? "" : `id=${JSON.stringify(this.id)}, `;
return `Job(${known}not refreshed)`;
}
const fields: string[] = [];
if (this.id !== null) {
fields.push(`id=${JSON.stringify(this.id)}`);
}
fields.push(`state=${JSON.stringify(this.state)}`);
if (this.jobType !== null) {
fields.push(`jobType=${JSON.stringify(this.jobType)}`);
}
if (this.creationMs !== null) {
fields.push(`creationMs=${this.creationMs}`);
}
for (const [name, value] of [
["spec", this.spec],
["result", this.result],
] as const) {
if (value !== null) {
fields.push(`${name}=${indentJson(value)}`);
}
}
if (this.failure !== null) {
fields.push(`failure=${indentJson(this.failure)}`);
}
return `Job(${fields.map((field) => `\n${REPR_INDENT}${field},`).join("")}\n)`;
}
[Symbol.for("nodejs.util.inspect.custom")](): string {
return this.toString();
}
}
const REPR_INDENT = " ";
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
function indentJson(value: any): string {
return JSON.stringify(value, null, 4).replace(/\n/g, `\n${REPR_INDENT}`);
}
// biome-ignore lint/suspicious/noExplicitAny: shape varies by job type
function parseJson(raw: string | null | undefined): any | null {
return raw === null || raw === undefined ? null : JSON.parse(raw);
}
+5 -1
View File
@@ -19,6 +19,8 @@ export interface MaterializedViewDefinition {
limit?: number; limit?: number;
/** Source columns the projections and filter read. */ /** Source columns the projections and filter read. */
inputs: string[]; inputs: string[];
/** Namespace holding the source table; empty is the root namespace. */
sourceNamespace: string[];
} }
/** /**
@@ -78,7 +80,8 @@ export function definitionFromMetadata(
} }
// biome-ignore lint/suspicious/noExplicitAny: raw JSON // biome-ignore lint/suspicious/noExplicitAny: raw JSON
const value: any = JSON.parse(raw); const value: any = JSON.parse(raw);
if (value.kind !== "select") { // "namespaced_select" keeps older readers from resolving the source at root.
if (value.kind !== "select" && value.kind !== "namespaced_select") {
throw new Error( throw new Error(
`materialized view '${name}' is defined by '${value.kind}', which this ` + `materialized view '${name}' is defined by '${value.kind}', which this ` +
"version of lancedb cannot refresh", "version of lancedb cannot refresh",
@@ -103,6 +106,7 @@ export function definitionFromMetadata(
filter: value.filter ?? undefined, filter: value.filter ?? undefined,
limit, limit,
inputs: value.inputs ?? [], inputs: value.inputs ?? [],
sourceNamespace: value.source_namespace ?? [],
}; };
} }
+5 -5
View File
@@ -727,11 +727,11 @@ export class VectorQuery extends StandardQueryBase<NativeVectorQuery> {
* Add a query vector to the search * Add a query vector to the search
* *
* This method can be called multiple times to add multiple query vectors * This method can be called multiple times to add multiple query vectors
* to the search. If multiple query vectors are added, then they will be searched * to the search. A column called `query_index` will be added to indicate the index
* in parallel, and the results will be concatenated. A column called `query_index` * of the query vector that produced the result. Flat searches share one table scan
* will be added to indicate the index of the query vector that produced the result. * across the query vectors, avoiding the scan and memory amplification of running
* * multiple queries concurrently. Indexed searches may still perform per-vector
* Performance wise, this is equivalent to running multiple queries concurrently. * index work.
*/ */
addQueryVector(vector: IntoVector): VectorQuery { addQueryVector(vector: IntoVector): VectorQuery {
if (vector instanceof Promise) { if (vector instanceof Promise) {
+11 -4
View File
@@ -94,17 +94,24 @@ export function sanitizeMetadata(
if (metadataLike === undefined || metadataLike === null) { if (metadataLike === undefined || metadataLike === null) {
return undefined; return undefined;
} }
if (!(metadataLike instanceof Map)) {
let entries: IterableIterator<[unknown, unknown]>;
try {
entries = Map.prototype.entries.call(metadataLike);
} catch {
throw Error("Expected metadata, if present, to be a Map<string, string>"); throw Error("Expected metadata, if present, to be a Map<string, string>");
} }
for (const item of metadataLike) {
if (typeof item[0] !== "string" || typeof item[1] !== "string") { const metadata = new Map<string, string>();
for (const [key, value] of entries) {
if (typeof key !== "string" || typeof value !== "string") {
throw Error( throw Error(
"Expected metadata, if present, to be a Map<string, string> but it had non-string keys or values", "Expected metadata, if present, to be a Map<string, string> but it had non-string keys or values",
); );
} }
metadata.set(key, value);
} }
return metadataLike as Map<string, string>; return metadata;
} }
export function sanitizeInt(typeLike: object) { export function sanitizeInt(typeLike: object) {
+2 -1
View File
@@ -406,10 +406,11 @@ function matchingFields(fields: Field[], tree: FieldTree): Field[] {
field.name, field.name,
new Struct(matchingFields(struct.children, value)), new Struct(matchingFields(struct.children, value)),
field.nullable, field.nullable,
field.metadata,
), ),
); );
} else { } else {
matches.push(new Field(field.name, value as DataType, field.nullable)); matches.push(field);
} }
} }
return matches; return matches;
+35 -10
View File
@@ -19,6 +19,7 @@ import {
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry"; import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
import { IndexOptions } from "./indices"; import { IndexOptions } from "./indices";
import { Job } from "./job";
import { MergeInsertBuilder } from "./merge"; import { MergeInsertBuilder } from "./merge";
import { import {
AddColumnsResult, AddColumnsResult,
@@ -30,7 +31,6 @@ import {
DropColumnsResult, DropColumnsResult,
IndexConfig, IndexConfig,
IndexStatistics, IndexStatistics,
Job,
LsmStats, LsmStats,
Branches as NativeBranches, Branches as NativeBranches,
OptimizeStats, OptimizeStats,
@@ -630,6 +630,18 @@ export abstract class Table {
/** /**
* Update per-field (column) metadata. * Update per-field (column) metadata.
*
* The following keys are treated specially, by convention, and should be
* used when appropriate:
*
* - `lancedb:description`: for a human-readable description of a field.
* - `lancedb:tag:<name>`: for a user-defined key-value tag, where the suffix
* names the tag category; e.g. `lancedb:tag:model: "clip"`.
* - `lancedb:logical-column`: for a column grouping; e.g. `feature_v1` and
* `feature_v2` might be in the same logical column.
* - `lancedb:status`: for status options (`production`, `candidate`,
* `deprecated`, `archived`) to designate the current life cycle state of
* this column.
* @param {FieldMetadataUpdate[]} updates One or more per-field updates. Each * @param {FieldMetadataUpdate[]} updates One or more per-field updates. Each
* update's metadata is merged into the field's existing metadata by default; * update's metadata is merged into the field's existing metadata by default;
* a value of `null` deletes that key, and `replace: true` swaps the whole map. * a value of `null` deletes that key, and `replace: true` swaps the whole map.
@@ -907,6 +919,16 @@ export abstract class Table {
/** Return the table as an arrow table */ /** Return the table as an arrow table */
abstract toArrow(): Promise<ArrowTable>; abstract toArrow(): Promise<ArrowTable>;
/**
* Create a {@link MergeInsertBuilder}, which combines new data with the
* existing table in a single transaction inserting, updating and deleting
* rows depending on how they match.
*
* @param on - The column, or columns, to match source rows against target
* rows on. Typically a key or id column. Several columns match on the
* composite key: a source row updates a target row only when it agrees on
* every one of them.
*/
abstract mergeInsert(on: string | string[]): MergeInsertBuilder; abstract mergeInsert(on: string | string[]): MergeInsertBuilder;
/** List all the stats of a specified index /** List all the stats of a specified index
@@ -1102,13 +1124,15 @@ export class LocalTable extends Table {
): Promise<Job> { ): Promise<Job> {
// biome-ignore lint/suspicious/noExplicitAny: skip // biome-ignore lint/suspicious/noExplicitAny: skip
const nativeIndex = (options?.config as any)?.inner; const nativeIndex = (options?.config as any)?.inner;
return await this.inner.createIndexAsync( return new Job(
nativeIndex, await this.inner.createIndexAsync(
column, nativeIndex,
options?.replace, column,
options?.waitTimeoutSeconds, options?.replace,
options?.name, options?.waitTimeoutSeconds,
options?.train, options?.name,
options?.train,
),
); );
} }
@@ -1291,7 +1315,7 @@ export class LocalTable extends Table {
} }
async refreshColumnAsync(column: string): Promise<Job> { async refreshColumnAsync(column: string): Promise<Job> {
return await this.inner.refreshColumnAsync(column); return new Job(await this.inner.refreshColumnAsync(column));
} }
async refreshMaterializedView( async refreshMaterializedView(
@@ -1555,7 +1579,8 @@ export interface FieldMetadataUpdate {
path: string; path: string;
/** /**
* Metadata key/value pairs. Merged into the field's existing metadata by * Metadata key/value pairs. Merged into the field's existing metadata by
* default; a value of `null` deletes that key. * default; a value of `null` deletes that key. See
* {@link Table.updateFieldMetadata} for the conventional `lancedb:*` keys.
*/ */
metadata: Record<string, string | null>; metadata: Record<string, string | null>;
/** If true, replace the field's entire metadata map instead of merging. */ /** If true, replace the field's entire metadata map instead of merging. */
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-darwin-arm64", "name": "@lancedb/lancedb-darwin-arm64",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": ["darwin"], "os": ["darwin"],
"cpu": ["arm64"], "cpu": ["arm64"],
"main": "lancedb.darwin-arm64.node", "main": "lancedb.darwin-arm64.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-arm64-gnu", "name": "@lancedb/lancedb-linux-arm64-gnu",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"], "cpu": ["arm64"],
"main": "lancedb.linux-arm64-gnu.node", "main": "lancedb.linux-arm64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-arm64-musl", "name": "@lancedb/lancedb-linux-arm64-musl",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["arm64"], "cpu": ["arm64"],
"main": "lancedb.linux-arm64-musl.node", "main": "lancedb.linux-arm64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-x64-gnu", "name": "@lancedb/lancedb-linux-x64-gnu",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"], "cpu": ["x64"],
"main": "lancedb.linux-x64-gnu.node", "main": "lancedb.linux-x64-gnu.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-linux-x64-musl", "name": "@lancedb/lancedb-linux-x64-musl",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": ["linux"], "os": ["linux"],
"cpu": ["x64"], "cpu": ["x64"],
"main": "lancedb.linux-x64-musl.node", "main": "lancedb.linux-x64-musl.node",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-win32-arm64-msvc", "name": "@lancedb/lancedb-win32-arm64-msvc",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": [ "os": [
"win32" "win32"
], ],
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@lancedb/lancedb-win32-x64-msvc", "name": "@lancedb/lancedb-win32-x64-msvc",
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"os": ["win32"], "os": ["win32"],
"cpu": ["x64"], "cpu": ["x64"],
"main": "lancedb.win32-x64-msvc.node", "main": "lancedb.win32-x64-msvc.node",
-11106
View File
File diff suppressed because it is too large Load Diff
+5 -5
View File
@@ -11,7 +11,7 @@
"ann" "ann"
], ],
"private": false, "private": false,
"version": "0.38.0-beta.10", "version": "0.39.0-beta.4",
"main": "dist/index.js", "main": "dist/index.js",
"exports": { "exports": {
".": "./dist/index.js", ".": "./dist/index.js",
@@ -44,7 +44,7 @@
"@biomejs/biome": "^1.7.3", "@biomejs/biome": "^1.7.3",
"@jest/globals": "^29.7.0", "@jest/globals": "^29.7.0",
"@napi-rs/cli": "3.7.0", "@napi-rs/cli": "3.7.0",
"@opentelemetry/sdk-metrics": "^1.30.0", "@opentelemetry/sdk-metrics": "^2.10.0",
"@types/axios": "^0.14.0", "@types/axios": "^0.14.0",
"@types/jest": "^29.1.2", "@types/jest": "^29.1.2",
"@types/node": "22.7.4", "@types/node": "22.7.4",
@@ -56,7 +56,7 @@
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"shx": "^0.3.4", "shx": "^0.3.4",
"tmp": "^0.2.3", "tmp": "^0.2.7",
"ts-jest": "^29.1.2", "ts-jest": "^29.1.2",
"typedoc": "0.26.4", "typedoc": "0.26.4",
"typedoc-plugin-markdown": "4.2.1", "typedoc-plugin-markdown": "4.2.1",
@@ -67,7 +67,7 @@
"timeout": "3m" "timeout": "3m"
}, },
"engines": { "engines": {
"node": ">= 18" "node": ">= 22"
}, },
"packageManager": "pnpm@11.1.1", "packageManager": "pnpm@11.1.1",
"cpu": ["x64", "arm64"], "cpu": ["x64", "arm64"],
@@ -101,7 +101,7 @@
"openai": "4.29.2" "openai": "4.29.2"
}, },
"peerDependencies": { "peerDependencies": {
"@types/node": ">=18", "@types/node": ">=22",
"apache-arrow": ">=15.0.0 <=18.1.0" "apache-arrow": ">=15.0.0 <=18.1.0"
}, },
"peerDependenciesMeta": { "peerDependenciesMeta": {
+600 -444
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -16,3 +16,41 @@ allowBuilds:
onnxruntime-node: true onnxruntime-node: true
protobufjs: true protobufjs: true
sharp: true sharp: true
minimumReleaseAgeExclude:
- protobufjs@7.5.8
- tmp@0.2.6
- form-data@4.0.6
- tar@7.5.16
- markdown-it@14.1.2
- linkify-it@5.0.1
- js-yaml@3.15.0
- js-yaml@4.1.2
- protobufjs@7.6.1
- protobufjs@7.6.3
- '@babel/core@7.29.1'
- axios@1.18.0
- brace-expansion@2.1.2
- brace-expansion@1.1.16
- js-yaml@4.3.0
- tar@7.5.18
- tar@7.5.19
- tar@7.5.17
- protobufjs@7.6.5
- linkify-it@5.0.2
- sharp@0.35.0
- brace-expansion@1.1.17
- brace-expansion@2.1.3
- brace-expansion@2.1.4
- brace-expansion@1.1.18
- js-yaml@3.15.1
- js-yaml@4.3.1
- tar@7.5.21
- '@opentelemetry/core@2.8.0'
# @huggingface/transformers pins sharp ^0.33.5 and no released version has moved
# past ^0.34.5, all of which inherit the libvips CVEs in GHSA-f88m-g3jw-g9cj.
# Force the patched line. sharp is only reached by transformers' image pipeline,
# which LanceDB's text embedding function never uses.
overrides:
sharp: ^0.35.4
+8 -45
View File
@@ -442,13 +442,15 @@ impl Connection {
self.get_inner()?.drop_all_tables(&ns).await.default_error() self.get_inner()?.drop_all_tables(&ns).await.default_error()
} }
/// A `Job` handle for a server-side job by id. /// Open a server-side job by id, returning a handle with its record
/// already populated. Rejects when the server has no such job.
/// ///
/// The handle is constructed without a server round trip; an unknown id /// The returned handle answers for its own state, specification, result,
/// surfaces when the handle is used. /// failure and event history, so there is no separate connection-level
#[napi] /// call for any of them.
pub fn job(&self, job_id: String) -> napi::Result<crate::job::Job> { #[napi(catch_unwind)]
let job = self.get_inner()?.job(job_id).default_error()?; pub async fn open_job(&self, job_id: String) -> napi::Result<crate::job::Job> {
let job = self.get_inner()?.open_job(&job_id).await.default_error()?;
Ok(crate::job::Job::new(job)) Ok(crate::job::Job::new(job))
} }
@@ -459,17 +461,6 @@ impl Connection {
Ok(jobs.into_iter().map(Into::into).collect()) Ok(jobs.into_iter().map(Into::into).collect())
} }
/// Describe a single server-side job by id. `null` when the server has
/// no such job.
#[napi(catch_unwind)]
pub async fn get_job(
&self,
job_id: String,
) -> napi::Result<Option<crate::job::JobDescription>> {
let description = self.get_inner()?.get_job(&job_id).await.default_error()?;
Ok(description.map(Into::into))
}
/// Request cancellation of a server-side job by id. Returns true if the /// Request cancellation of a server-side job by id. Returns true if the
/// server accepted the cancellation, false if no such job exists. /// server accepted the cancellation, false if no such job exists.
#[napi(catch_unwind)] #[napi(catch_unwind)]
@@ -477,34 +468,6 @@ impl Connection {
self.get_inner()?.cancel_job(&job_id).await.default_error() self.get_inner()?.cancel_job(&job_id).await.default_error()
} }
/// The lifecycle event history of a server-side job (all jobs when
/// `job_id` is null), as an Arrow IPC stream buffer. Empty when there is
/// no history.
#[napi(catch_unwind)]
pub async fn job_history(&self, job_id: Option<String>) -> napi::Result<Buffer> {
let batches = self
.get_inner()?
.job_history(job_id.as_deref())
.await
.default_error()?;
let Some(first) = batches.first() else {
return Ok(Buffer::from(Vec::<u8>::new()));
};
let mut out = Vec::new();
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema())
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
for batch in &batches {
writer
.write(batch)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
}
writer
.finish()
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
drop(writer);
Ok(Buffer::from(out))
}
#[napi(catch_unwind)] #[napi(catch_unwind)]
/// Describe a namespace and return its properties. /// Describe a namespace and return its properties.
pub async fn describe_namespace( pub async fn describe_namespace(
+90 -34
View File
@@ -3,6 +3,9 @@
use std::sync::Arc; use std::sync::Arc;
use arrow_array::RecordBatch;
use lancedb::job::JobEventsRequest;
use napi::bindgen_prelude::Buffer;
use napi_derive::napi; use napi_derive::napi;
use crate::error::NapiErrorExt; use crate::error::NapiErrorExt;
@@ -55,12 +58,98 @@ impl Job {
pub async fn cancel(&self) -> napi::Result<()> { pub async fn cancel(&self) -> napi::Result<()> {
self.inner.cancel().await.default_error() self.inner.cancel().await.default_error()
} }
/// Ask the backend for this job's current state, and for a server-side job
/// its full record, then cache it for the getters below.
///
/// They are all null until this runs, because submitting an operation
/// returns only a job id. {@link Job.status} fetches the whole record too;
/// {@link Job.wait} records only the terminal state it establishes.
#[napi(catch_unwind)]
pub async fn refresh(&self) -> napi::Result<()> {
self.inner.refresh().await.default_error()
}
/// The last observed lifecycle state, without contacting the backend.
#[napi(getter)]
pub fn state(&self) -> Option<String> {
self.inner.state()
}
/// The job's type, as the server names it. Null for an in-process job,
/// which has no server-side record.
#[napi(getter)]
pub fn job_type(&self) -> Option<String> {
self.inner.job_type()
}
/// When the job was created, in milliseconds since the epoch.
#[napi(getter)]
pub fn creation_ms(&self) -> Option<i64> {
self.inner.creation_ms()
}
/// The job-type-specific specification as a JSON string, when present.
#[napi(getter)]
pub fn spec_json(&self) -> Option<String> {
self.inner.spec().map(|spec| spec.to_string())
}
/// The job-type-specific terminal result as a JSON string. Null until the
/// job succeeds, so a job that never terminates reports its progress
/// through {@link Job.events} instead.
#[napi(getter)]
pub fn result_json(&self) -> Option<String> {
self.inner.result().map(|result| result.to_string())
}
/// Why the job failed, when it failed and the server reports a reason.
#[napi(getter)]
pub fn failure(&self) -> Option<JobFailureInfo> {
self.inner.failure().map(|failure| JobFailureInfo {
phase: failure.phase,
message: failure.message,
retryable: failure.retryable,
})
}
/// This job's recorded lifecycle events, as an Arrow IPC stream buffer.
/// The TypeScript wrapper turns it into an Arrow table.
#[napi(catch_unwind)]
pub async fn events(&self, limit: Option<u32>, filter: Option<String>) -> napi::Result<Buffer> {
let batches = self
.inner
.events(JobEventsRequest { limit, filter })
.await
.default_error()?;
batches_to_ipc_buffer(&batches)
}
}
/// Serialise Arrow batches as a single IPC stream for the TypeScript layer.
fn batches_to_ipc_buffer(batches: &[RecordBatch]) -> napi::Result<Buffer> {
let Some(first) = batches.first() else {
return Ok(Buffer::from(Vec::<u8>::new()));
};
let mut out = Vec::new();
let mut writer = arrow_ipc::writer::StreamWriter::try_new(&mut out, &first.schema())
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
for batch in batches {
writer
.write(batch)
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
}
writer
.finish()
.map_err(|e| napi::Error::from_reason(e.to_string()))?;
drop(writer);
Ok(Buffer::from(out))
} }
/// A row from `Connection.listJobs`: one server-side job. /// A row from `Connection.listJobs`: one server-side job.
#[napi(object)] #[napi(object)]
pub struct JobInfo { pub struct JobInfo {
/// The job id -- what `Connection.getJob` and `Connection.cancelJob` /// The job id -- what `Connection.openJob` and `Connection.cancelJob`
/// accept. /// accept.
pub job_id: String, pub job_id: String,
/// The table the job runs against, without URI or namespace. /// The table the job runs against, without URI or namespace.
@@ -91,36 +180,3 @@ pub struct JobFailureInfo {
pub message: Option<String>, pub message: Option<String>,
pub retryable: Option<bool>, pub retryable: Option<bool>,
} }
/// A described job from `Connection.getJob`.
#[napi(object)]
pub struct JobDescription {
pub job_id: String,
pub job_type: String,
/// Lifecycle state: "running", "finished", "failed", or "cancelled".
pub state: String,
/// When the job was created, in milliseconds since the epoch.
pub creation_ms: i64,
/// The job-type-specific specification as a JSON string, when present.
pub spec_json: Option<String>,
/// Why the job failed, when the job is failed and the server reports a
/// reason.
pub failure: Option<JobFailureInfo>,
}
impl From<lancedb::database::JobDescription> for JobDescription {
fn from(description: lancedb::database::JobDescription) -> Self {
Self {
job_id: description.job_id,
job_type: description.job_type,
state: description.state,
creation_ms: description.creation_ms,
spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()),
failure: description.failure.map(|failure| JobFailureInfo {
phase: failure.phase,
message: failure.message,
retryable: failure.retryable,
}),
}
}
}
+5 -1
View File
@@ -664,7 +664,11 @@ impl JsFullTextQuery {
} }
fn parse_fts_query(query: Object) -> napi::Result<FullTextSearchQuery> { fn parse_fts_query(query: Object) -> napi::Result<FullTextSearchQuery> {
if let Ok(Some(query)) = query.get::<&JsFullTextQuery>("query") { // `&JsFullTextQuery` recovers a native class reference through napi's borrow-tracked
// path, which is only usable from generated `#[napi]` argument conversion. This is a
// manual lookup on a nested `Object` property instead, so use `ClassInstance`, which
// unwraps the class without requiring a borrow scope.
if let Ok(Some(query)) = query.get::<ClassInstance<JsFullTextQuery>>("query") {
Ok(FullTextSearchQuery::new_query(query.inner.clone())) Ok(FullTextSearchQuery::new_query(query.inner.clone()))
} else if let Ok(Some(query_text)) = query.get::<String>("query") { } else if let Ok(Some(query_text)) = query.get::<String>("query") {
let mut query_text = query_text; let mut query_text = query_text;
+3 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "lancedb-python" name = "lancedb-python"
version = "0.38.0-beta.10" version = "0.39.0-beta.4"
publish = false publish = false
edition.workspace = true edition.workspace = true
description = "Python bindings for LanceDB" description = "Python bindings for LanceDB"
@@ -28,7 +28,7 @@ env_logger.workspace = true
log.workspace = true log.workspace = true
# Maturin enables extension-module mode for Python builds. Keeping it out of # Maturin enables extension-module mode for Python builds. Keeping it out of
# Cargo features lets Rust unit tests link against libpython. # Cargo features lets Rust unit tests link against libpython.
pyo3 = { version = "0.28", features = ["abi3-py310", "chrono"] } pyo3 = { version = "0.28", features = ["abi3-py310", "chrono", "uuid"] }
chrono.workspace = true chrono.workspace = true
pyo3-async-runtimes = { version = "0.28", features = [ pyo3-async-runtimes = { version = "0.28", features = [
"attributes", "attributes",
@@ -40,6 +40,7 @@ serde.workspace = true
serde_json.workspace = true serde_json.workspace = true
snafu.workspace = true snafu.workspace = true
tokio.workspace = true tokio.workspace = true
uuid.workspace = true
libc = "0.2" libc = "0.2"
[build-dependencies] [build-dependencies]
+1
View File
@@ -139,6 +139,7 @@ include = [
"python/lancedb/exceptions.py", "python/lancedb/exceptions.py",
"python/lancedb/background_loop.py", "python/lancedb/background_loop.py",
"python/lancedb/schema.py", "python/lancedb/schema.py",
"python/lancedb/sql.py",
"python/lancedb/remote/__init__.py", "python/lancedb/remote/__init__.py",
"python/lancedb/remote/errors.py", "python/lancedb/remote/errors.py",
"python/lancedb/embeddings/__init__.py", "python/lancedb/embeddings/__init__.py",
+35 -2
View File
@@ -6,7 +6,7 @@ import importlib.metadata
import os import os
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta from datetime import timedelta
from typing import Dict, Optional, Union, Any, List, Iterable from typing import Dict, Optional, Union, Any, List, Iterable, TYPE_CHECKING
__version__ = importlib.metadata.version("lancedb") __version__ = importlib.metadata.version("lancedb")
@@ -20,9 +20,13 @@ from .db import AsyncConnection, DBConnection, LanceDBConnection
from .remote import ClientConfig from .remote import ClientConfig
from .remote.db import RemoteDBConnection from .remote.db import RemoteDBConnection
from .expr import Expr, col, lit, func from .expr import Expr, col, lit, func
from .schema import blob, vector, BlobType from .schema import blob, vector
from .job import AsyncJob, Job from .job import AsyncJob, Job
from .sql import AsyncQuery as AsyncSqlQuery
from .sql import Query as SqlQuery
from .sql import QueryDescription
from .functions import ( from .functions import (
AssignmentMapping as AssignmentMapping,
FunctionArtifactRequest as FunctionArtifactRequest, FunctionArtifactRequest as FunctionArtifactRequest,
FunctionApplication as FunctionApplication, FunctionApplication as FunctionApplication,
FunctionBinding as FunctionBinding, FunctionBinding as FunctionBinding,
@@ -33,6 +37,8 @@ from .functions import (
UdfDefinition as UdfDefinition, UdfDefinition as UdfDefinition,
udf as udf, udf as udf,
) )
from .secrets import EnvVarSecret as EnvVarSecret
from .secrets import SecretInfo as SecretInfo
from .materialized_view import ( from .materialized_view import (
AsyncMaterializedView, AsyncMaterializedView,
MaterializedView, MaterializedView,
@@ -49,6 +55,19 @@ from .namespace import (
) )
if TYPE_CHECKING:
from lance.blob import BlobType as BlobType
def __getattr__(name: str):
if name == "BlobType":
from .schema import BlobType
globals()["BlobType"] = BlobType
return BlobType
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def _check_s3_bucket_with_dots( def _check_s3_bucket_with_dots(
uri: str, storage_options: Optional[Dict[str, str]] uri: str, storage_options: Optional[Dict[str, str]]
) -> None: ) -> None:
@@ -88,6 +107,7 @@ def connect(
api_key: Optional[str] = None, api_key: Optional[str] = None,
region: str = "us-east-1", region: str = "us-east-1",
host_override: Optional[str] = None, host_override: Optional[str] = None,
sql_host_override: Optional[str] = None,
read_consistency_interval: Optional[timedelta] = None, read_consistency_interval: Optional[timedelta] = None,
request_thread_pool: Optional[Union[int, ThreadPoolExecutor]] = None, request_thread_pool: Optional[Union[int, ThreadPoolExecutor]] = None,
client_config: Union[ClientConfig, Dict[str, Any], None] = None, client_config: Union[ClientConfig, Dict[str, Any], None] = None,
@@ -116,6 +136,9 @@ def connect(
The region to use for LanceDB Cloud. The region to use for LanceDB Cloud.
host_override: str, optional host_override: str, optional
The override url for LanceDB Cloud. The override url for LanceDB Cloud.
sql_host_override: str, optional
The remote SQL service endpoint override. The client connects lazily when SQL
is first executed and retains that connection.
read_consistency_interval: timedelta, default None read_consistency_interval: timedelta, default None
The interval at which to check for updates to the table from other The interval at which to check for updates to the table from other
processes. If None, then consistency is not checked. For performance processes. If None, then consistency is not checked. For performance
@@ -257,6 +280,7 @@ def connect(
api_key, api_key,
region, region,
host_override, host_override,
sql_host_override=sql_host_override,
# TODO: remove this (deprecation warning downstream) # TODO: remove this (deprecation warning downstream)
request_thread_pool=request_thread_pool, request_thread_pool=request_thread_pool,
client_config=client_config, client_config=client_config,
@@ -399,6 +423,7 @@ def deserialize_conn(
parsed["api_key"], parsed["api_key"],
parsed.get("region", "us-east-1"), parsed.get("region", "us-east-1"),
host_override=parsed.get("host_override"), host_override=parsed.get("host_override"),
sql_host_override=parsed.get("sql_host_override"),
client_config=parsed.get("client_config"), client_config=parsed.get("client_config"),
storage_options=storage_options, storage_options=storage_options,
) )
@@ -412,6 +437,7 @@ async def connect_async(
api_key: Optional[str] = None, api_key: Optional[str] = None,
region: str = "us-east-1", region: str = "us-east-1",
host_override: Optional[str] = None, host_override: Optional[str] = None,
sql_host_override: Optional[str] = None,
read_consistency_interval: Optional[timedelta] = None, read_consistency_interval: Optional[timedelta] = None,
client_config: Optional[Union[ClientConfig, Dict[str, Any]]] = None, client_config: Optional[Union[ClientConfig, Dict[str, Any]]] = None,
storage_options: Optional[Dict[str, str]] = None, storage_options: Optional[Dict[str, str]] = None,
@@ -434,6 +460,9 @@ async def connect_async(
The region to use for LanceDB Cloud. The region to use for LanceDB Cloud.
host_override: str, optional host_override: str, optional
The override url for LanceDB Cloud. The override url for LanceDB Cloud.
sql_host_override: str, optional
The remote SQL service endpoint override. The client connects lazily when SQL
is first executed and retains that connection.
read_consistency_interval: timedelta, default None read_consistency_interval: timedelta, default None
The interval at which to check for updates to the table from other The interval at which to check for updates to the table from other
processes. If None, then consistency is not checked. For performance processes. If None, then consistency is not checked. For performance
@@ -521,6 +550,7 @@ async def connect_async(
api_key, api_key,
region, region,
host_override, host_override,
sql_host_override,
read_consistency_interval_secs, read_consistency_interval_secs,
client_config, client_config,
storage_options, storage_options,
@@ -543,6 +573,7 @@ __all__ = [
"connect_namespace_async", "connect_namespace_async",
"AsyncConnection", "AsyncConnection",
"AsyncJob", "AsyncJob",
"AsyncSqlQuery",
"AsyncLanceNamespaceDBConnection", "AsyncLanceNamespaceDBConnection",
"AsyncTable", "AsyncTable",
"FtsToken", "FtsToken",
@@ -557,6 +588,8 @@ __all__ = [
"vector", "vector",
"DBConnection", "DBConnection",
"Job", "Job",
"QueryDescription",
"SqlQuery",
"LanceDBConnection", "LanceDBConnection",
"LanceNamespaceDBConnection", "LanceNamespaceDBConnection",
"LsmWriteSpec", "LsmWriteSpec",
+9 -5
View File
@@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Optional, Union
import pyarrow as pa import pyarrow as pa
from .expr import Expr from .expr import Expr
from .schema import blob_v2_column_paths from .schema import row_addressable_blob_v2_paths
from .types import BlobMode, QueryProjection, QueryProjectionSpec from .types import BlobMode, QueryProjection, QueryProjectionSpec
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -119,7 +119,7 @@ def blob_v2_projection_sources(
schema: pa.Schema, schema: pa.Schema,
projection: QueryProjection, projection: QueryProjection,
) -> dict[str, str]: ) -> dict[str, str]:
blob_columns = blob_v2_column_paths(schema) blob_columns = row_addressable_blob_v2_paths(schema)
if not blob_columns: if not blob_columns:
return {} return {}
columns = set(blob_columns) columns = set(blob_columns)
@@ -140,7 +140,9 @@ def v2_projection_needs_row_id(
) -> bool: ) -> bool:
if with_row_id: if with_row_id:
return False return False
return projection_includes_blob_column(projection, blob_v2_column_paths(schema)) return projection_includes_blob_column(
projection, row_addressable_blob_v2_paths(schema)
)
def blob_auto_row_id_for_scan( def blob_auto_row_id_for_scan(
@@ -270,7 +272,8 @@ def _iter_projection_pairs(
if isinstance(expr, str): if isinstance(expr, str):
yield name, expr yield name, expr
elif isinstance(expr, Expr): elif isinstance(expr, Expr):
yield name, expr.to_sql() source = expr._column_name()
yield name, source if source is not None else expr.to_sql()
return return
for column in projection: for column in projection:
if isinstance(column, str): if isinstance(column, str):
@@ -280,7 +283,8 @@ def _iter_projection_pairs(
if isinstance(expr, str): if isinstance(expr, str):
yield name, expr yield name, expr
elif isinstance(expr, Expr): elif isinstance(expr, Expr):
yield name, expr.to_sql() source = expr._column_name()
yield name, source if source is not None else expr.to_sql()
def _set_blob_column(tbl: pa.Table, output_name: str, blobs: pa.Array) -> pa.Table: def _set_blob_column(tbl: pa.Table, output_name: str, blobs: pa.Array) -> pa.Table:
+55 -6
View File
@@ -1,6 +1,7 @@
from datetime import date, datetime, timedelta from datetime import date, datetime, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Dict, List, Optional, Tuple, Any, TypedDict, Union, Literal from typing import Dict, List, Optional, Tuple, Any, TypedDict, Union, Literal
from uuid import UUID
import pyarrow as pa import pyarrow as pa
@@ -87,6 +88,7 @@ class PyExpr:
def contains(self, substr: "PyExpr") -> "PyExpr": ... def contains(self, substr: "PyExpr") -> "PyExpr": ...
def isin(self, values: List["PyExpr"]) -> "PyExpr": ... def isin(self, values: List["PyExpr"]) -> "PyExpr": ...
def cast(self, data_type: pa.DataType) -> "PyExpr": ... def cast(self, data_type: pa.DataType) -> "PyExpr": ...
def column_name(self) -> Optional[str]: ...
def to_sql(self) -> str: ... def to_sql(self) -> str: ...
def expr_col(name: str) -> PyExpr: ... def expr_col(name: str) -> PyExpr: ...
@@ -146,15 +148,25 @@ class Connection(object):
start_after: Optional[str], start_after: Optional[str],
limit: Optional[int], limit: Optional[int],
) -> list[str]: ... # Deprecated: Use list_tables instead ) -> list[str]: ... # Deprecated: Use list_tables instead
def job(self, job_id: str) -> Job: ... async def open_job(self, job_id: str) -> Job: ...
async def create_function_async(self, request_json: str) -> Job: ... async def create_function_async(self, request_json: str) -> Job: ...
async def get_function(self, name: str, version: str) -> str: ... async def get_function(self, name: str, version: str) -> str: ...
async def list_functions(self) -> List[str]: ...
async def drop_function(self, name: str, version: str) -> bool: ...
async def create_secret(self, name: str, value: str) -> None: ...
async def alter_secret(self, name: str, value: str) -> None: ...
async def list_secrets(self) -> List[str]: ...
async def drop_secret(self, name: str) -> None: ...
async def describe_secret(self, name: str) -> Dict[str, str]: ...
async def list_jobs(self) -> List[JobInfo]: ... async def list_jobs(self) -> List[JobInfo]: ...
async def get_job(self, job_id: str) -> Optional[JobDescription]: ...
async def cancel_job(self, job_id: str) -> bool: ... async def cancel_job(self, job_id: str) -> bool: ...
async def job_history( async def execute_query_async(
self, job_id: Optional[str] = None self,
) -> List[pa.RecordBatch]: ... query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> SqlQuery: ...
async def describe_query(self, query_id: UUID) -> QueryDescription: ...
async def create_table( async def create_table(
self, self,
name: str, name: str,
@@ -233,9 +245,20 @@ class BlobFile:
class Job: class Job:
@property @property
def id(self) -> Optional[str]: ... def id(self) -> Optional[str]: ...
@property
def _state(self) -> Optional[str]: ...
@property
def _description(self) -> Optional[JobDescription]: ...
async def status(self) -> str: ... async def status(self) -> str: ...
async def wait(self) -> Optional[str]: ... async def wait(self) -> Optional[str]: ...
async def cancel(self) -> None: ... async def cancel(self) -> None: ...
async def refresh(self) -> None: ...
async def events(
self,
*,
limit: Optional[int] = None,
filter: Optional[str] = None,
) -> pa.Table: ...
class JobInfo: class JobInfo:
@property @property
@@ -267,10 +290,33 @@ class JobDescription:
@property @property
def creation_ms(self) -> int: ... def creation_ms(self) -> int: ...
@property @property
def spec_json(self) -> Optional[str]: ... def _spec_json(self) -> Optional[str]: ...
@property
def _result_json(self) -> Optional[str]: ...
@property
def spec(self) -> Optional[Any]: ...
@property
def result(self) -> Optional[Any]: ...
@property @property
def failure(self) -> Optional[JobFailureInfo]: ... def failure(self) -> Optional[JobFailureInfo]: ...
class SqlQuery:
@property
def id(self) -> UUID: ...
async def describe(self) -> QueryDescription: ...
async def reader(self) -> RecordBatchStream: ...
async def cancel(self) -> None: ...
class QueryDescription:
@property
def id(self) -> UUID: ...
@property
def status(self) -> str: ...
@property
def progress(self) -> Optional[float]: ...
@property
def expires_at(self) -> Optional[datetime]: ...
class Table: class Table:
def name(self) -> str: ... def name(self) -> str: ...
def __repr__(self) -> str: ... def __repr__(self) -> str: ...
@@ -449,6 +495,7 @@ async def connect(
api_key: Optional[str], api_key: Optional[str],
region: Optional[str], region: Optional[str],
host_override: Optional[str], host_override: Optional[str],
sql_host_override: Optional[str],
read_consistency_interval: Optional[float], read_consistency_interval: Optional[float],
client_config: Optional[Union[ClientConfig, Dict[str, Any]]], client_config: Optional[Union[ClientConfig, Dict[str, Any]]],
storage_options: Optional[Dict[str, str]], storage_options: Optional[Dict[str, str]],
@@ -605,9 +652,11 @@ class FullTextQuery:
class PyQueryRequest: class PyQueryRequest:
limit: Optional[int] limit: Optional[int]
offset: Optional[int] offset: Optional[int]
take_offsets: Optional[List[int]]
filter: Optional[Union[str, bytes]] filter: Optional[Union[str, bytes]]
full_text_search: Optional[FullTextQuery] full_text_search: Optional[FullTextQuery]
select: Optional[Union[str, List[str]]] select: Optional[Union[str, List[str]]]
select_source_columns: Optional[Dict[str, str]]
fast_search: Optional[bool] fast_search: Optional[bool]
with_row_id: Optional[bool] with_row_id: Optional[bool]
use_lsm: Optional[bool] use_lsm: Optional[bool]
+305 -73
View File
@@ -17,8 +17,10 @@ from typing import (
List, List,
Literal, Literal,
Optional, Optional,
Sequence,
Union, Union,
) )
from uuid import UUID
if sys.version_info >= (3, 12): if sys.version_info >= (3, 12):
from typing import override from typing import override
@@ -47,12 +49,16 @@ from . import __version__
from ._lancedb import connect as lancedb_connect # type: ignore from ._lancedb import connect as lancedb_connect # type: ignore
from .functions import FunctionVersion, UdfDefinition from .functions import FunctionVersion, UdfDefinition
from .job import AsyncJob, Job, _typed_job from .job import AsyncJob, Job, _typed_job
from .sql import AsyncQuery as AsyncSqlQuery
from .sql import Query as SqlQuery
from .sql import QueryDescription
from .materialized_view import ( from .materialized_view import (
AsyncMaterializedView, AsyncMaterializedView,
MaterializedView, MaterializedView,
SelectArg, SelectArg,
normalize_select, normalize_select,
) )
from .secrets import EnvVarSecret, SecretInfo, validate_secret_name
from .table import ( from .table import (
AsyncTable, AsyncTable,
LanceTable, LanceTable,
@@ -68,10 +74,11 @@ import deprecation
if TYPE_CHECKING: if TYPE_CHECKING:
import pyarrow as pa import pyarrow as pa
from .arrow import AsyncRecordBatchReader
from .pydantic import LanceModel from .pydantic import LanceModel
from ._lancedb import Connection as LanceDbConnection from ._lancedb import Connection as LanceDbConnection
from ._lancedb import JobDescription, JobInfo from ._lancedb import JobInfo
from .common import DATA, URI from .common import DATA, URI
from .embeddings import EmbeddingFunctionConfig from .embeddings import EmbeddingFunctionConfig
from ._lancedb import Session from ._lancedb import Session
@@ -687,15 +694,47 @@ class DBConnection(EnforceOverrides):
""" """
raise NotImplementedError("serialize is not supported for this connection type") 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[Sequence[EnvVarSecret]] = None,
) -> FunctionVersion:
"""Register a scalar Python UDF and wait for its immutable version. """Register a scalar Python UDF and wait for its immutable version.
This is the blocking counterpart of :meth:`create_function_async`. This is the blocking counterpart of :meth:`create_function_async`.
Local connections raise ``NotImplementedError``. Local connections raise ``NotImplementedError``.
"""
return self.create_function_async(definition).wait()
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: Parameters
----------
definition : UdfDefinition
A callable decorated with [udf][lancedb.udf].
secrets : sequence of EnvVarSecret, optional
One [EnvVarSecret][lancedb.secrets.EnvVarSecret] per credential the
Function needs, each naming a Secret and the environment variable
its value arrives in. The Function's source is unchanged by this;
it reads the variable the way it already did.
Examples
--------
```python
db.create_secret("openai-prod", os.environ["OPENAI_API_KEY"])
db.create_function(
analyze_caption,
secrets=[
EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
],
)
```
"""
return self.create_function_async(definition, secrets=secrets).wait()
def create_function_async(
self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> Job[FunctionVersion]:
"""Register a scalar Python UDF through the remote Function catalog. """Register a scalar Python UDF through the remote Function catalog.
Submission returns a typed job. The immutable Function version becomes Submission returns a typed job. The immutable Function version becomes
@@ -712,26 +751,107 @@ class DBConnection(EnforceOverrides):
"Function catalog operations are not supported for this connection type" "Function catalog operations are not supported for this connection type"
) )
def job(self, job_id: str) -> Job: def list_functions(self) -> List[FunctionVersion]:
"""A [Job][lancedb.job.Job] handle for a server-side job by id. """List every published immutable Function version.
The handle is constructed without a server round trip; an unknown id Results are ordered by Function name then version. Local connections
surfaces when the handle is used. Dropping the handle has no effect raise ``NotImplementedError``.
on the job itself.
Examples
--------
List the identities available to use in Function-backed columns:
```python
[(function.name, function.version) for function in db.list_functions()]
```
""" """
raise NotImplementedError("job is not supported for this connection type") raise NotImplementedError(
"Function catalog operations are not supported for this connection type"
)
def drop_function(self, name: str, *, version: str) -> bool:
"""Drop one exact immutable Function version from the remote catalog.
Returns True when the version changed to Dropped and False for an
idempotent replay. Local connections raise NotImplementedError.
"""
raise NotImplementedError(
"Function catalog operations are not supported for this connection type"
)
def create_secret(self, name: str, value: str) -> None:
"""Create a named Secret in this database.
Fails if the name is taken, so a create never silently becomes a
rotation. Nothing reads the value back: it is bound to a Function by
name and resolved by the service when that Function runs. Local
connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def alter_secret(self, name: str, value: str) -> None:
"""Replace the credential behind an existing Secret.
Fails if it does not exist. Every Function bound to the Secret uses the
new value from its next job, and no new Function version is created --
which is how a rotation reaches columns pinned to a version registered
before it. Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def list_secrets(self) -> List[str]:
"""The names of every Secret in this database.
Names only. No method returns a stored credential, by construction
rather than by policy. Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def drop_secret(self, name: str) -> None:
"""Drop a Secret.
Functions bound to it fail at their next job, naming the Secret; that
is the revocation path. The name becomes free to reuse, and a new
Secret under it is picked up by everything still bound to that name.
Local connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def describe_secret(self, name: str) -> SecretInfo:
"""What this database records about a Secret: name and timestamps.
Never the value -- there is no code path that could return one. Local
connections raise ``NotImplementedError``.
"""
raise NotImplementedError(
"Secret operations are not supported for this connection type"
)
def open_job(self, job_id: str) -> Job:
"""Open a server-side job by id, returning a handle with its record
already populated.
The returned [Job][lancedb.job.Job] answers for its own state,
specification, result, failure and event history, so there is no
separate connection-level call for any of them.
Raises `JobNotFoundError` when the server has no such job, the way
`open_table` does for a missing table.
"""
raise NotImplementedError("open_job is not supported for this connection type")
def list_jobs(self) -> List[JobInfo]: def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
raise NotImplementedError("list_jobs is not supported for this connection type") raise NotImplementedError("list_jobs is not supported for this connection type")
def get_job(self, job_id: str) -> Optional[JobDescription]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
raise NotImplementedError("get_job is not supported for this connection type")
def cancel_job(self, job_id: str) -> bool: def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id. """Request cancellation of a server-side job by id.
@@ -743,14 +863,38 @@ class DBConnection(EnforceOverrides):
"cancel_job is not supported for this connection type" "cancel_job is not supported for this connection type"
) )
def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: def execute_query(
"""The lifecycle event history of a server-side job, as Arrow batches. self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> pa.RecordBatchReader:
"""Execute SQL and return a blocking Arrow reader.
Lists history across all jobs when `job_id` is None. This submits through :meth:`execute_query_async` and waits until the
initial result stream is readable. It does not wait for the full query
to finish.
""" """
raise NotImplementedError( return self.execute_query_async(
"job_history is not supported for this connection type" query,
) default_namespace_path=default_namespace_path,
).reader()
def execute_query_async(
self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> SqlQuery:
"""Start executing SQL and return its query handle.
Local connections do not support SQL.
"""
raise NotImplementedError("SQL is not supported for this connection type")
def describe_query(self, query_id: UUID) -> QueryDescription:
"""Describe a submitted SQL query by its connection-scoped id."""
raise NotImplementedError("SQL is not supported for this connection type")
class LanceDBConnection(DBConnection): class LanceDBConnection(DBConnection):
@@ -847,6 +991,7 @@ class LanceDBConnection(DBConnection):
None, None,
None, None,
None, None,
None,
read_consistency_interval_secs, read_consistency_interval_secs,
None, None,
storage_options, storage_options,
@@ -1395,37 +1540,59 @@ class LanceDBConnection(DBConnection):
) )
@override @override
def job(self, job_id: str) -> Job: def open_job(self, job_id: str) -> Job:
"""A [Job][lancedb.job.Job] handle for a server-side job by id. """Open a server-side job by id. See
[DBConnection.open_job][lancedb.db.DBConnection.open_job].
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
""" """
return Job(self._conn.job(job_id)) return Job(LOOP.run(self._conn.open_job(job_id)))
@override @override
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: def create_function_async(
job = LOOP.run(self._conn.create_function_async(definition)) self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> Job[FunctionVersion]:
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
return Job(job) return Job(job)
@override @override
def get_function(self, name: str, *, version: str) -> FunctionVersion: def get_function(self, name: str, *, version: str) -> FunctionVersion:
return LOOP.run(self._conn.get_function(name, version=version)) return LOOP.run(self._conn.get_function(name, version=version))
@override
def list_functions(self) -> List[FunctionVersion]:
return LOOP.run(self._conn.list_functions())
@override
def drop_function(self, name: str, *, version: str) -> bool:
return LOOP.run(self._conn.drop_function(name, version=version))
@override
def create_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.create_secret(name, value))
@override
def alter_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.alter_secret(name, value))
@override
def list_secrets(self) -> List[str]:
return LOOP.run(self._conn.list_secrets())
@override
def drop_secret(self, name: str) -> None:
LOOP.run(self._conn.drop_secret(name))
@override
def describe_secret(self, name: str) -> SecretInfo:
return LOOP.run(self._conn.describe_secret(name))
@override @override
def list_jobs(self) -> List[JobInfo]: def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
return LOOP.run(self._conn.list_jobs()) return LOOP.run(self._conn.list_jobs())
@override
def get_job(self, job_id: str) -> Optional[JobDescription]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
return LOOP.run(self._conn.get_job(job_id))
@override @override
def cancel_job(self, job_id: str) -> bool: def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id. """Request cancellation of a server-side job by id.
@@ -1436,14 +1603,6 @@ class LanceDBConnection(DBConnection):
""" """
return LOOP.run(self._conn.cancel_job(job_id)) return LOOP.run(self._conn.cancel_job(job_id))
@override
def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]:
"""The lifecycle event history of a server-side job, as Arrow batches.
Lists history across all jobs when `job_id` is None.
"""
return LOOP.run(self._conn.job_history(job_id))
@override @override
def namespace_client(self) -> LanceNamespace: def namespace_client(self) -> LanceNamespace:
"""Get the equivalent namespace client for this connection. """Get the equivalent namespace client for this connection.
@@ -2214,46 +2373,85 @@ class AsyncConnection(object):
namespace_path = [] namespace_path = []
await self._inner.drop_all_tables(namespace_path=namespace_path) await self._inner.drop_all_tables(namespace_path=namespace_path)
def job(self, job_id: str) -> AsyncJob: async def open_job(self, job_id: str) -> AsyncJob:
"""An [AsyncJob][lancedb.job.AsyncJob] handle for a server-side job """Open a server-side job by id. See
by id. [DBConnection.open_job][lancedb.db.DBConnection.open_job].
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
""" """
return AsyncJob(self._inner.job(job_id)) return AsyncJob(await self._inner.open_job(job_id))
async def create_function_async( async def create_function_async(
self, definition: UdfDefinition self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> AsyncJob[FunctionVersion]: ) -> AsyncJob[FunctionVersion]:
"""Register a scalar Python UDF through the remote Function catalog. """Register a scalar Python UDF through the remote Function catalog.
The returned typed job resolves to the immutable Function version. The returned typed job resolves to the immutable Function version.
Local connections raise ``NotImplementedError``. ``secrets`` is a sequence of
[EnvVarSecret][lancedb.secrets.EnvVarSecret], each naming a Secret and
the environment variable its value arrives in. Local connections raise
``NotImplementedError``.
""" """
if not isinstance(definition, UdfDefinition): if not isinstance(definition, UdfDefinition):
raise TypeError("create_function_async requires a @udf definition") raise TypeError("create_function_async requires a @udf definition")
inner = await self._inner.create_function_async( request = definition.bind_secrets(secrets)
definition.registration_request.to_canonical_json() inner = await self._inner.create_function_async(request.to_canonical_json())
)
return _typed_job(inner, FunctionVersion.from_json) return _typed_job(inner, FunctionVersion.from_json)
async def get_function(self, name: str, *, version: str) -> FunctionVersion: async def get_function(self, name: str, *, version: str) -> FunctionVersion:
"""Open one exact immutable Function version from the remote catalog.""" """Open one exact immutable Function version from the remote catalog."""
return FunctionVersion.from_json(await self._inner.get_function(name, version)) return FunctionVersion.from_json(await self._inner.get_function(name, version))
async def list_functions(self) -> List[FunctionVersion]:
"""List every published immutable Function version.
Results are ordered by Function name then version. Local connections
raise ``NotImplementedError``.
"""
return [
FunctionVersion.from_json(value)
for value in await self._inner.list_functions()
]
async def drop_function(self, name: str, *, version: str) -> bool:
"""Drop one exact immutable Function version from the remote catalog."""
return await self._inner.drop_function(name, version)
async def create_secret(self, name: str, value: str) -> None:
"""Create a named Secret in this database.
Fails if the name is taken, so a create never silently becomes a
rotation. Nothing reads the value back.
"""
await self._inner.create_secret(validate_secret_name(name), value)
async def alter_secret(self, name: str, value: str) -> None:
"""Replace the credential behind an existing Secret.
Fails if it does not exist. Bound Functions use the new value from
their next job, with no new Function version.
"""
await self._inner.alter_secret(validate_secret_name(name), value)
async def list_secrets(self) -> List[str]:
"""The names of every Secret in this database. Names only."""
return await self._inner.list_secrets()
async def drop_secret(self, name: str) -> None:
"""Drop a Secret. Bound Functions fail at their next job."""
await self._inner.drop_secret(validate_secret_name(name))
async def describe_secret(self, name: str) -> SecretInfo:
"""What this database records about a Secret. Never the value."""
return SecretInfo.from_json(
await self._inner.describe_secret(validate_secret_name(name))
)
async def list_jobs(self) -> List[JobInfo]: async def list_jobs(self) -> List[JobInfo]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
return await self._inner.list_jobs() return await self._inner.list_jobs()
async def get_job(self, job_id: str) -> Optional[JobDescription]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
return await self._inner.get_job(job_id)
async def cancel_job(self, job_id: str) -> bool: async def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id. """Request cancellation of a server-side job by id.
@@ -2263,12 +2461,46 @@ class AsyncConnection(object):
""" """
return await self._inner.cancel_job(job_id) return await self._inner.cancel_job(job_id)
async def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: async def execute_query(
"""The lifecycle event history of a server-side job, as Arrow batches. self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> AsyncRecordBatchReader:
"""Execute SQL and return an asynchronous Arrow reader.
Lists history across all jobs when `job_id` is None. This submits through :meth:`execute_query_async` and waits until the
initial result stream is readable. It does not wait for the full query
to finish.
""" """
return await self._inner.job_history(job_id) submitted = await self.execute_query_async(
query,
default_namespace_path=default_namespace_path,
)
return await submitted.reader()
async def execute_query_async(
self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> AsyncSqlQuery:
"""Start executing SQL and return its query handle.
The database from ``connect_async`` is used for unqualified database
references. The namespace defaults to ``["public"]``. Local
connections raise ``NotImplementedError``.
"""
return AsyncSqlQuery(
await self._inner.execute_query_async(
query,
default_namespace_path=default_namespace_path,
)
)
async def describe_query(self, query_id: UUID) -> QueryDescription:
"""Describe a submitted SQL query by its connection-scoped id."""
return await self._inner.describe_query(query_id)
async def namespace_client(self) -> LanceNamespace: async def namespace_client(self) -> LanceNamespace:
"""Get the equivalent namespace client for this connection. """Get the equivalent namespace client for this connection.
+6
View File
@@ -35,3 +35,9 @@ class JobCancelledError(RuntimeError):
"""Exception raised when an asynchronous job was cancelled.""" """Exception raised when an asynchronous job was cancelled."""
pass pass
class JobNotFoundError(ValueError):
"""Exception raised when opening a job the server does not have."""
pass
+5 -1
View File
@@ -249,6 +249,10 @@ class Expr:
# ── utilities ──────────────────────────────────────────────────────────── # ── utilities ────────────────────────────────────────────────────────────
def _column_name(self) -> str | None:
"""Return the source name when this is a bare column expression."""
return self._inner.column_name()
def to_sql(self) -> str: def to_sql(self) -> str:
"""Render the expression as a SQL string (useful for debugging).""" """Render the expression as a SQL string (useful for debugging)."""
return self._inner.to_sql() return self._inner.to_sql()
@@ -312,7 +316,7 @@ def func(name: str, *args: ExprLike) -> Expr:
-------- --------
>>> from lancedb.expr import col, func >>> from lancedb.expr import col, func
>>> func("lower", col("name")) >>> func("lower", col("name"))
Expr(lower(name)) Expr(lower(`name`))
""" """
inner_args = [_coerce(a)._inner for a in args] inner_args = [_coerce(a)._inner for a in args]
return Expr(expr_func(name, inner_args)) return Expr(expr_func(name, inner_args))
+433 -40
View File
@@ -4,7 +4,7 @@
"""Canonical Function values exchanged with LanceDB Enterprise services. """Canonical Function values exchanged with LanceDB Enterprise services.
These immutable models contain client/wire state only. Catalog persistence, 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 ``RefreshColumnResult`` is also the backend-neutral result of a local
expression-backed refresh job. expression-backed refresh job.
""" """
@@ -25,7 +25,7 @@ import re
import sys import sys
import textwrap import textwrap
import types import types
from collections.abc import Mapping from collections.abc import Mapping, Sequence
from datetime import date, datetime from datetime import date, datetime
from typing import ( from typing import (
Annotated, Annotated,
@@ -49,11 +49,26 @@ from pydantic import (
model_validator, model_validator,
) )
from .schema import is_blob_v2_field as _is_blob_v2_field
from .secrets import EnvVarSecret
_Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1) _Int32 = conint(strict=True, ge=-(2**31), le=2**31 - 1)
_UInt32 = conint(strict=True, ge=0, le=2**32 - 1) _UInt32 = conint(strict=True, ge=0, le=2**32 - 1)
_UInt64 = conint(strict=True, ge=0, le=2**64 - 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): class _FrozenDict(dict):
def _immutable(self, *args, **kwargs): def _immutable(self, *args, **kwargs):
raise TypeError("remote canonical values are immutable") raise TypeError("remote canonical values are immutable")
@@ -222,6 +237,7 @@ class PythonEnvironmentSpec(_RemoteValue):
kind: str kind: str
packages: tuple[str, ...] = () packages: tuple[str, ...] = ()
channels: tuple[str, ...] = ()
path: Optional[str] = None path: Optional[str] = None
modules: tuple[str, ...] = () modules: tuple[str, ...] = ()
image: Optional[str] = None image: Optional[str] = None
@@ -238,6 +254,23 @@ class PythonRuntimeSpec(_RemoteValue):
python_version: Optional[str] = None python_version: Optional[str] = None
environment: Optional[PythonEnvironmentSpec] = None environment: Optional[PythonEnvironmentSpec] = None
env: Optional[Mapping[str, str]] = 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") @model_validator(mode="after")
def _validate_runtime_kind(self): def _validate_runtime_kind(self):
@@ -246,18 +279,28 @@ class PythonRuntimeSpec(_RemoteValue):
raise ValueError("python runtime requires python_version") raise ValueError("python runtime requires python_version")
if self.environment is None: if self.environment is None:
raise ValueError("python runtime requires environment") 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: else:
object.__setattr__(self, "python_version", None) object.__setattr__(self, "python_version", None)
object.__setattr__(self, "environment", None) object.__setattr__(self, "environment", None)
object.__setattr__(self, "env", None) object.__setattr__(self, "env", None)
object.__setattr__(self, "gpu", None)
return self return self
class FunctionVersion(_RemoteValue): class FunctionVersion(_RemoteValue):
"""An exact immutable Function version returned by Enterprise. """An exact immutable Function version returned by Enterprise.
Scheduling resources, priority, concurrency, and retry policy belong to The GPU execution requirement is part of this identity. CPU and memory sizing,
the submitting Job and are not part of this identity. priority, concurrency, and retry policy belong to the execution platform.
""" """
name: str name: str
@@ -267,6 +310,7 @@ class FunctionVersion(_RemoteValue):
runtime: PythonRuntimeSpec runtime: PythonRuntimeSpec
runtime_digest: str runtime_digest: str
environment_digest: str environment_digest: str
secret_env_bindings: Mapping[str, str] = {}
created_at: str created_at: str
def __call__(self, **inputs: Any) -> FunctionApplication: def __call__(self, **inputs: Any) -> FunctionApplication:
@@ -328,12 +372,18 @@ class FunctionVersion(_RemoteValue):
class FunctionRegistrationRequest(_RemoteValue): class FunctionRegistrationRequest(_RemoteValue):
"""Stable remote registration envelope produced by :func:`udf`.""" """Stable remote registration envelope produced by :func:`udf`.
Credential values deliberately have no field here. The only secret-shaped
thing a client sends is ``secret_env_bindings``: the name of a Secret the
database already holds, which the remote service resolves at execution.
"""
name: str name: str
artifact: FunctionArtifactRequest artifact: FunctionArtifactRequest
signature: FunctionSignature signature: FunctionSignature
runtime: PythonRuntimeSpec runtime: PythonRuntimeSpec
secret_env_bindings: Mapping[str, str] = {}
class FunctionVersionRef(_OpenRemoteValue): class FunctionVersionRef(_OpenRemoteValue):
@@ -428,11 +478,7 @@ class InputBinding(_RemoteValue):
class OutputMapping(_RemoteValue): class OutputMapping(_RemoteValue):
"""One stable result-field mapping. """One stable result-field mapping."""
Assignment state is outside the Slice 1 client contract. During the NULL
transition Lance exposes no public cell-flag identifier to persist here.
"""
result_field: str result_field: str
output_name: str output_name: str
@@ -442,6 +488,13 @@ class OutputMapping(_RemoteValue):
nullable: bool nullable: bool
class AssignmentMapping(_RemoteValue):
"""Internal physical column preserving flattened struct validity."""
output_name: str
output_field_id: _Int32
class FunctionBinding(_RemoteValue): class FunctionBinding(_RemoteValue):
"""Immutable Function binding persisted by the Enterprise table service.""" """Immutable Function binding persisted by the Enterprise table service."""
@@ -449,6 +502,7 @@ class FunctionBinding(_RemoteValue):
function: FunctionVersionRef function: FunctionVersionRef
inputs: tuple[InputBinding, ...] inputs: tuple[InputBinding, ...]
outputs: tuple[OutputMapping, ...] outputs: tuple[OutputMapping, ...]
assignment: Optional[AssignmentMapping] = None
input_schema: Optional[Mapping[str, Any]] = None input_schema: Optional[Mapping[str, Any]] = None
output_schema: Optional[Mapping[str, Any]] = None output_schema: Optional[Mapping[str, Any]] = None
@@ -478,6 +532,15 @@ class RefreshColumnResult(_RemoteValue):
_FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$") _FUNCTION_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*$")
_DECLARED_SECRET = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
_FUNCTION_BLOB_V2_TYPE = "blob_v2"
_ARROW_EXTENSION_NAME_KEY = "ARROW:extension:name"
_BLOB_V2_EXTENSION_NAME = "lance.blob.v2"
_NESTED_BLOB_COLLECTION_ERROR = (
"unsupported Arrow type for Function signature: Blob v2 fields nested under "
"collection types are not supported"
)
_GRAMMAR_PRIMITIVES = ( _GRAMMAR_PRIMITIVES = (
@@ -494,6 +557,7 @@ _GRAMMAR_PRIMITIVES = (
(pa.float32(), "float32"), (pa.float32(), "float32"),
(pa.float64(), "float64"), (pa.float64(), "float64"),
(pa.string(), "utf8"), (pa.string(), "utf8"),
(pa.large_string(), "large_utf8"),
(pa.binary(), "binary"), (pa.binary(), "binary"),
(pa.date32(), "date32"), (pa.date32(), "date32"),
(pa.date64(), "date64"), (pa.date64(), "date64"),
@@ -501,31 +565,258 @@ _GRAMMAR_PRIMITIVES = (
def _canonical_arrow_type(data_type: pa.DataType) -> str: def _canonical_arrow_type(data_type: pa.DataType) -> str:
"""The server's V1 Function type grammar. Anything outside it is rejected """The compact Function grammar, or canonical exact JSON for nested types."""
here rather than at registration.""" 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]:
for candidate, name in _GRAMMAR_PRIMITIVES: for candidate, name in _GRAMMAR_PRIMITIVES:
if data_type == candidate: if data_type == candidate:
return name return name
if pa.types.is_list(data_type) or pa.types.is_large_list(data_type): 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" prefix = "list" if pa.types.is_list(data_type) else "large_list"
return f"{prefix}<{_canonical_list_item(data_type)}>" return f"{prefix}<{item}>"
if pa.types.is_fixed_size_list(data_type) and data_type.list_size > 0: if pa.types.is_fixed_size_list(data_type) and data_type.list_size > 0:
return ( item = _grammar_list_item(data_type)
f"fixed_size_list<{_canonical_list_item(data_type)}, {data_type.list_size}>" if item is not None:
) return f"fixed_size_list<{item}, {data_type.list_size}>"
raise TypeError(f"unsupported Arrow type for Function signature: {data_type}") return None
def _canonical_list_item(data_type: pa.DataType) -> str: def _grammar_list_item(data_type: pa.DataType) -> Optional[str]:
"""The grammar names only the item type; it always means a non-nullable """The grammar names only the item type; it always means a non-nullable
child called `item`, so any other child metadata cannot be represented.""" child called `item`, so other child properties require exact JSON."""
child = data_type.value_field child = data_type.value_field
if child.name != "item" or child.nullable or child.metadata: 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( raise TypeError(
"unsupported Arrow type for Function signature: list items must be a " "unsupported Arrow type for Function signature: field names "
f"non-nullable field named 'item', got {child}" "must not be empty"
) )
return _canonical_arrow_type(child.type) 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}"
)
metadata = {
(key.decode() if isinstance(key, bytes) else key): (
value.decode() if isinstance(value, bytes) else value
)
for key, value in (field.metadata or {}).items()
}
if metadata and metadata != {
_ARROW_EXTENSION_NAME_KEY: _BLOB_V2_EXTENSION_NAME
}:
raise TypeError(
"unsupported Arrow type for Function signature: lance.blob.v2 "
"field metadata must contain only its canonical extension marker"
)
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 _blob_storage_type(field: pa.Field) -> pa.DataType:
data_type = field.type
if isinstance(data_type, pa.ExtensionType):
return data_type.storage_type
return data_type
def _exact_blob_storage_type(field: pa.Field) -> dict[str, Any]:
storage = _blob_storage_type(field)
if not pa.types.is_struct(storage):
raise TypeError(
"unsupported Arrow type for Function signature: lance.blob.v2 "
"requires struct storage"
)
return {
"type": "struct",
"fields": [
{
"name": child.name,
"nullable": child.nullable,
"type": (
{"type": "large_binary"}
if pa.types.is_large_binary(child.type)
else _exact_arrow_type(child.type)
),
}
for child in storage
],
}
def _data_type_has_blob_v2(data_type: pa.DataType) -> bool:
if pa.types.is_struct(data_type):
return any(
_is_blob_v2_field(field) or _data_type_has_blob_v2(field.type)
for field in data_type
)
if (
pa.types.is_list(data_type)
or pa.types.is_large_list(data_type)
or pa.types.is_fixed_size_list(data_type)
):
field = data_type.value_field
return _is_blob_v2_field(field) or _data_type_has_blob_v2(field.type)
if pa.types.is_map(data_type):
return any(
_is_blob_v2_field(field) or _data_type_has_blob_v2(field.type)
for field in (data_type.key_field, data_type.item_field)
)
return False
def _exact_arrow_field(
field: pa.Field, *, inside_collection: bool = False
) -> dict[str, Any]:
_validate_exact_arrow_field(field)
if _is_blob_v2_field(field):
if inside_collection:
raise TypeError(_NESTED_BLOB_COLLECTION_ERROR)
return {
"name": field.name,
"nullable": field.nullable,
"type": _exact_blob_storage_type(field),
"metadata": {
_ARROW_EXTENSION_NAME_KEY: _BLOB_V2_EXTENSION_NAME,
},
}
value = {
"name": field.name,
"nullable": field.nullable,
"type": _exact_arrow_type(field.type, inside_collection=inside_collection),
}
return value
def _exact_arrow_type(
data_type: pa.DataType, *, inside_collection: bool = False
) -> 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, inside_collection=inside_collection)
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, inside_collection=True)
],
}
if pa.types.is_fixed_size_list(data_type):
value["length"] = data_type.list_size
return value
if pa.types.is_map(data_type) and _data_type_has_blob_v2(data_type):
raise TypeError(_NESTED_BLOB_COLLECTION_ERROR)
raise TypeError(f"unsupported Arrow type for Function signature: {data_type}")
def _list_of(item: pa.DataType) -> pa.DataType: def _list_of(item: pa.DataType) -> pa.DataType:
@@ -599,8 +890,15 @@ def _callable_parameters(function: Callable[..., Any]) -> tuple[inspect.Paramete
def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutput: def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutput:
if isinstance(output, pa.Schema): if isinstance(output, pa.Schema):
if output.metadata:
raise TypeError("Function output schema metadata is not supported")
fields = tuple(output) fields = tuple(output)
elif isinstance(output, pa.Field) and pa.types.is_struct(output.type): elif (
isinstance(output, pa.Field)
and not _is_blob_v2_field(output)
and pa.types.is_struct(output.type)
):
_validate_exact_arrow_field(output)
if output.nullable: if output.nullable:
raise ValueError("Function output must be non-nullable") raise ValueError("Function output must be non-nullable")
fields = tuple(output.type) fields = tuple(output.type)
@@ -616,18 +914,19 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp
raise TypeError( raise TypeError(
"output_schema must be a PyArrow DataType, Field, or Schema" "output_schema must be a PyArrow DataType, Field, or Schema"
) )
_validate_exact_arrow_field(field)
if field.nullable: if field.nullable:
raise ValueError("Function output must be non-nullable") raise ValueError("Function output must be non-nullable")
return FunctionOutput( return FunctionOutput(
kind="scalar", kind="scalar",
arrow_type=_canonical_arrow_type(field.type), arrow_type=_canonical_arrow_field(field),
nullable=False, nullable=False,
) )
if not fields: if not fields:
raise ValueError("named-struct Function output must contain at least one field") raise ValueError("named-struct Function output must contain at least one field")
if any(field.nullable for field in fields): for field in fields:
raise ValueError("Function output fields must be non-nullable") _validate_exact_arrow_field(field)
names = [field.name for field in fields] names = [field.name for field in fields]
if len(set(names)) != len(names): if len(set(names)) != len(names):
raise ValueError("Function output field names must be unique") raise ValueError("Function output field names must be unique")
@@ -636,8 +935,8 @@ def _function_output(output: pa.DataType | pa.Field | pa.Schema) -> FunctionOutp
fields=tuple( fields=tuple(
FunctionResultField( FunctionResultField(
name=field.name, name=field.name,
arrow_type=_canonical_arrow_type(field.type), arrow_type=_canonical_arrow_field(field),
nullable=False, nullable=field.nullable,
) )
for field in fields for field in fields
), ),
@@ -656,6 +955,10 @@ def _infer_signature(
if input_schema is not None: if input_schema is not None:
if not isinstance(input_schema, pa.Schema): if not isinstance(input_schema, pa.Schema):
raise TypeError("input_schema must be a PyArrow 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) expected = tuple(parameter.name for parameter in parameters)
actual = tuple(input_schema.names) actual = tuple(input_schema.names)
if actual != expected: if actual != expected:
@@ -666,7 +969,7 @@ def _infer_signature(
inputs = tuple( inputs = tuple(
FunctionParameter( FunctionParameter(
name=field.name, name=field.name,
arrow_type=_canonical_arrow_type(field.type), arrow_type=_canonical_arrow_field(field),
nullable=field.nullable, nullable=field.nullable,
) )
for field in input_schema for field in input_schema
@@ -689,7 +992,9 @@ def _infer_signature(
inputs.append( inputs.append(
FunctionParameter( FunctionParameter(
name=parameter.name, name=parameter.name,
arrow_type=_canonical_arrow_type(data_type), arrow_type=_canonical_arrow_field(
pa.field(parameter.name, data_type, nullable=nullable)
),
nullable=nullable, nullable=nullable,
) )
) )
@@ -909,13 +1214,26 @@ class UdfDefinition:
pip: tuple[str, ...], pip: tuple[str, ...],
env: Mapping[str, str], env: Mapping[str, str],
python_version: Optional[str], python_version: Optional[str],
gpu: bool = False,
conda: tuple[str, ...] = (),
conda_channels: tuple[str, ...] = (),
): ):
function_name = name or function.__name__ function_name = name or function.__name__
if not _FUNCTION_NAME.fullmatch(function_name): if not _FUNCTION_NAME.fullmatch(function_name):
raise ValueError(f"invalid Function name: {function_name!r}") raise ValueError(f"invalid Function name: {function_name!r}")
packages = tuple(sorted(set(pip))) if pip and conda:
raise ValueError("a Function environment is pip or conda, not both")
if conda_channels and not conda:
raise ValueError("conda_channels requires conda packages")
packages = tuple(sorted(set(conda if conda else pip)))
if any(not package or package != package.strip() for package in packages): if any(not package or package != package.strip() for package in packages):
raise ValueError("pip requirements must be non-empty and trimmed") raise ValueError("package requirements must be non-empty and trimmed")
if conda:
environment_spec = PythonEnvironmentSpec(
kind="conda", packages=packages, channels=tuple(conda_channels)
)
else:
environment_spec = PythonEnvironmentSpec(kind="pip", packages=packages)
environment = dict(env) environment = dict(env)
if any( if any(
not isinstance(key, str) or not isinstance(value, str) not isinstance(key, str) or not isinstance(value, str)
@@ -925,12 +1243,14 @@ class UdfDefinition:
signature = _infer_signature(function, input_schema, output_schema) signature = _infer_signature(function, input_schema, output_schema)
source = _package_source(function) source = _package_source(function)
digest = f"sha256:{hashlib.sha256(source).hexdigest()}" digest = f"sha256:{hashlib.sha256(source).hexdigest()}"
gpu_marker = _normalize_gpu_marker(gpu)
runtime = PythonRuntimeSpec( runtime = PythonRuntimeSpec(
kind="python", kind="python_v2" if gpu_marker is not None else "python",
python_version=python_version python_version=python_version
or f"{sys.version_info.major}.{sys.version_info.minor}", or f"{sys.version_info.major}.{sys.version_info.minor}",
environment=PythonEnvironmentSpec(kind="pip", packages=packages), environment=environment_spec,
env=environment, env=environment,
gpu=gpu_marker,
) )
self._function = function self._function = function
self._request = FunctionRegistrationRequest( self._request = FunctionRegistrationRequest(
@@ -955,9 +1275,56 @@ class UdfDefinition:
@property @property
def registration_request(self) -> FunctionRegistrationRequest: def registration_request(self) -> FunctionRegistrationRequest:
"""The immutable request sent by ``create_function_async``.""" """The immutable request sent by ``create_function_async``.
Carries no secret bindings. Binding is a registration-time decision,
so a Function bound to Secrets is registered through :meth:`bind_secrets`,
which is what ``create_function`` calls.
"""
return self._request return self._request
def bind_secrets(
self, secrets: Optional[Sequence[EnvVarSecret]]
) -> FunctionRegistrationRequest:
"""The registration request for this definition bound to ``secrets``.
Binding does not change the Function's source: each
[EnvVarSecret][lancedb.secrets.EnvVarSecret] names a Secret and the
environment variable its value should arrive in, and the Function reads
that variable the way it already did. Whether the named Secrets exist is
the server's answer, not this one.
"""
bindings = () if secrets is None else tuple(secrets)
wrong_type = [
binding for binding in bindings if not isinstance(binding, EnvVarSecret)
]
if wrong_type:
kinds = sorted({type(binding).__name__ for binding in wrong_type})
raise TypeError(
f"Function secrets must be EnvVarSecret values, not {kinds!r}; a "
"credential value is never sent to this API"
)
variables = [binding.env_variable for binding in bindings]
duplicates = sorted({name for name in variables if variables.count(name) > 1})
if duplicates:
raise ValueError(
"a Function binds each environment variable once; duplicated: "
f"{duplicates!r}"
)
# `env` is ordinary configuration carried in the definition, so a name in
# both would have a value visible in the Function's record and a value
# that is not. Refuse rather than pick.
environment = self._request.runtime.env or {}
overlap = sorted(set(environment) & set(variables))
if overlap:
raise ValueError(
f"Function env and secret bindings must be disjoint: {overlap!r}"
)
if not bindings:
return self._request
resolved = {binding.env_variable: binding.secret for binding in bindings}
return self._request._copy(update={"secret_env_bindings": resolved})
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
return self._function(*args, **kwargs) return self._function(*args, **kwargs)
@@ -976,6 +1343,9 @@ def udf(
pip: tuple[str, ...] | list[str] = (), pip: tuple[str, ...] | list[str] = (),
env: Optional[Mapping[str, str]] = None, env: Optional[Mapping[str, str]] = None,
python_version: Optional[str] = None, python_version: Optional[str] = None,
gpu: bool = False,
conda: tuple[str, ...] | list[str] = (),
conda_channels: tuple[str, ...] | list[str] = (),
) -> Callable[[Callable[..., Any]], UdfDefinition]: ... ) -> Callable[[Callable[..., Any]], UdfDefinition]: ...
@@ -988,13 +1358,17 @@ def udf(
pip: tuple[str, ...] | list[str] = (), pip: tuple[str, ...] | list[str] = (),
env: Optional[Mapping[str, str]] = None, env: Optional[Mapping[str, str]] = None,
python_version: Optional[str] = None, python_version: Optional[str] = None,
gpu: bool = False,
conda: tuple[str, ...] | list[str] = (),
conda_channels: tuple[str, ...] | list[str] = (),
): ):
"""Prepare a scalar Python callable for remote Function registration. """Prepare a scalar Python callable for remote Function registration.
Input and output signatures are inferred from supported annotations. For Input and output signatures are inferred from supported annotations. For
Arrow types annotations cannot express precisely, pass ``input_schema`` Arrow types annotations cannot express precisely, pass ``input_schema``
and ``output_schema`` together. Nullable outputs are rejected because V1 and ``output_schema`` together. Scalar outputs must be non-nullable. Every
uses physical NULL to represent unassigned computed-column rows. named-struct field may be nullable; Enterprise preserves the struct's
validity when the result is expanded into sibling columns.
Parameters Parameters
---------- ----------
@@ -1006,14 +1380,24 @@ def udf(
Explicit input fields in the exact order of the callable parameters. Explicit input fields in the exact order of the callable parameters.
Must be provided together with ``output_schema``. Must be provided together with ``output_schema``.
output_schema : pyarrow.DataType, pyarrow.Field, or pyarrow.Schema, optional output_schema : pyarrow.DataType, pyarrow.Field, or pyarrow.Schema, optional
Explicit scalar or named-struct output. Must be non-nullable and be Explicit scalar or named-struct output. Scalar outputs must be
provided together with ``input_schema``. non-nullable. Must be provided together with ``input_schema``.
pip : sequence of str, optional pip : sequence of str, optional
Pip requirements for the remote environment. Pip requirements for the remote environment.
conda : sequence of str, optional
Conda packages for the remote environment, instead of ``pip``.
conda_channels : sequence of str, optional
Conda channels in priority order; requires ``conda``.
env : mapping of str to str, optional env : mapping of str to str, optional
Environment variables included in the Function definition. Environment variables included in the Function definition. Not for
credentials -- these are ordinary configuration, stored with the
Function and visible wherever it is.
python_version : str, optional python_version : str, optional
Remote Python major/minor version. Defaults to the client version. 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 packaged artifact is a snapshot: the function source plus exactly
the module-level names it references (modules as imports, importable the module-level names it references (modules as imports, importable
@@ -1038,6 +1422,11 @@ def udf(
... return value * 2 ... return value * 2
>>> score(1.5) >>> score(1.5)
3.0 3.0
>>> @udf(pip=["cupy-cuda12x"], gpu=True)
... def gpu_score(value: int) -> int:
... return value * 2
>>> gpu_score.registration_request.runtime.gpu
True
""" """
def decorate(target: Callable[..., Any]) -> UdfDefinition: def decorate(target: Callable[..., Any]) -> UdfDefinition:
@@ -1049,6 +1438,9 @@ def udf(
pip=tuple(pip), pip=tuple(pip),
env={} if env is None else env, env={} if env is None else env,
python_version=python_version, python_version=python_version,
gpu=gpu,
conda=tuple(conda),
conda_channels=tuple(conda_channels),
) )
if function is None: if function is None:
@@ -1057,6 +1449,7 @@ def udf(
__all__ = [ __all__ = [
"AssignmentMapping",
"ApplicationInput", "ApplicationInput",
"FunctionApplication", "FunctionApplication",
"FunctionArtifact", "FunctionArtifact",
+12
View File
@@ -7,6 +7,7 @@ from typing import List, Literal, Optional
from ._lancedb import ( from ._lancedb import (
IndexConfig, IndexConfig,
) )
from .query import DocumentGranularity
from .types import BaseTokenizerType from .types import BaseTokenizerType
lang_mapping = { lang_mapping = {
@@ -121,6 +122,11 @@ class FTS:
>>> config = FTS(block_size=256) >>> config = FTS(block_size=256)
Create an index that treats each deepest-list element as one document:
>>> from lancedb.query import DocumentGranularity
>>> config = FTS(document_granularity=DocumentGranularity.LIST_ELEMENT)
Attributes Attributes
---------- ----------
with_position : bool, default False with_position : bool, default False
@@ -172,6 +178,11 @@ class FTS:
roughly half of the available CPU cores. The effective value is roughly half of the available CPU cores. The effective value is
limited by the available compute capacity. This build-only setting is limited by the available compute capacity. This build-only setting is
not persisted with the index and does not apply to remote tables. not persisted with the index and does not apply to remote tables.
document_granularity : DocumentGranularity, default ROW
``ROW`` treats the selected text in one table row as one document.
``LIST_ELEMENT`` treats each element of the deepest list on the indexed
field path as one document and returns its physical coordinates in
``_doc_index`` for matching queries.
Notes Notes
----- -----
@@ -196,6 +207,7 @@ class FTS:
custom_stop_words: Optional[List[str]] = None custom_stop_words: Optional[List[str]] = None
memory_limit: Optional[int] = None memory_limit: Optional[int] = None
num_workers: Optional[int] = None num_workers: Optional[int] = None
document_granularity: DocumentGranularity = DocumentGranularity.ROW
@dataclass @dataclass
+224
View File
@@ -4,15 +4,27 @@
"""Handles to operations a server may run asynchronously.""" """Handles to operations a server may run asynchronously."""
import asyncio import asyncio
import json
from datetime import timedelta from datetime import timedelta
from typing import Any, Callable, Generic, Optional, TypeVar, cast from typing import Any, Callable, Generic, Optional, TypeVar, cast
import pyarrow as pa
from lancedb.background_loop import LOOP from lancedb.background_loop import LOOP
from . import _lancedb from . import _lancedb
from ._lancedb import JobDescription, JobFailureInfo, JobInfo
T = TypeVar("T") T = TypeVar("T")
__all__ = [
"AsyncJob",
"Job",
"JobDescription",
"JobFailureInfo",
"JobInfo",
]
class AsyncJob(Generic[T]): class AsyncJob(Generic[T]):
"""A handle to an operation that may still be running. """A handle to an operation that may still be running.
@@ -78,6 +90,149 @@ class AsyncJob(Generic[T]):
return return
await self._inner.cancel() await self._inner.cancel()
async def refresh(self) -> None:
"""Ask the backend for this job's current state, and for a server-side
job its full record, then cache it for the properties below.
The properties are all `None` until this runs, because submitting an
operation returns only a job id. `status` fetches the whole record too;
`wait` records only the terminal state it establishes.
"""
if self._inner is None:
return
await self._inner.refresh()
@property
def state(self) -> Optional[str]:
"""The last observed lifecycle state, without contacting the backend.
`None` until the handle has talked to it. See :meth:`AsyncJob.refresh`.
"""
if self._inner is None:
return "finished"
return self._inner._state
@property
def job_type(self) -> Optional[str]:
"""The job's type, as the server names it.
`None` for an in-process job, which has no server-side record.
"""
return self._field("job_type")
@property
def creation_ms(self) -> Optional[int]:
"""When the job was created, in milliseconds since the epoch."""
return self._field("creation_ms")
@property
def spec(self) -> Optional[Any]:
"""The job-type-specific specification it was submitted with."""
return self._field("spec")
@property
def result(self) -> Optional[Any]:
"""The job-type-specific terminal result, as reported data rather than
the typed model :meth:`AsyncJob.wait` returns.
`None` until the job succeeds, so a job that never terminates reports
its progress through :meth:`AsyncJob.events` instead.
"""
return self._field("result")
@property
def failure(self) -> Optional[JobFailureInfo]:
"""Why the job failed, when it failed and the server reports a reason."""
return self._field("failure")
@property
def _spec_json(self) -> Optional[str]:
return self._field("_spec_json")
@property
def _result_json(self) -> Optional[str]:
return self._field("_result_json")
def _field(self, name: str) -> Optional[Any]:
description = self._inner._description if self._inner is not None else None
return getattr(description, name) if description is not None else None
async def events(
self,
*,
limit: Optional[int] = None,
filter: Optional[str] = None,
) -> "pa.Table":
"""This job's recorded lifecycle events.
Where the properties above report a terminal result only once the job
reaches one, events are written as the job runs and outlive the workers
that produced them. A distributed job records a `claim`/`claim_complete`
pair per unit of work, each carrying `rows_processed`, so a job that
never finishes still accounts for what it did.
Parameters
----------
limit: int, optional
Maximum event rows to return. The server caps results at 1000 by
default and 10,000 at most, and truncates without saying so, so
pass this for a job that emits an event per fragment.
filter: str, optional
SQL-like expression over the `state`, `updated_by`, `emitted_from`,
`emitted_by`, and `claim_entity` columns, such as
``state = 'claim_complete'``.
"""
if self._inner is None:
raise NotImplementedError(
"job event history is only available for server-side jobs"
)
return await self._inner.events(limit=limit, filter=filter)
def __repr__(self) -> str:
return _job_repr("AsyncJob", self)
_REPR_INDENT = " " * 4
def _repr_payload(value: Any) -> str:
"""Render a job payload as indented JSON, aligned under its field."""
try:
rendered = json.dumps(value, indent=4)
except TypeError:
return repr(value)
return rendered.replace("\n", "\n" + _REPR_INDENT)
def _job_repr(kind: str, job: Any) -> str:
"""Render every field the handle currently knows, omitting the rest.
One field per line, with the JSON payloads indented, because a refresh
job's spec and result are the point of printing it.
"""
state = job.state
if state is None:
# Nothing has been fetched yet, so there is nothing to lay out.
known = f"id={job.id!r}, " if job.id is not None else ""
return f"{kind}({known}not refreshed)"
fields = []
if job.id is not None:
fields.append(f"id={job.id!r}")
fields.append(f"state={state!r}")
for name in ("job_type", "creation_ms"):
value = getattr(job, name)
if value is not None:
fields.append(f"{name}={value!r}")
for name in ("spec", "result"):
value = getattr(job, name)
if value is not None:
fields.append(f"{name}={_repr_payload(value)}")
if job.failure is not None:
fields.append(f"failure={job.failure!r}")
body = "".join(f"\n{_REPR_INDENT}{field}," for field in fields)
return f"{kind}({body}\n)"
class Job(Generic[T]): class Job(Generic[T]):
"""Synchronous counterpart of `AsyncJob` with the same result type.""" """Synchronous counterpart of `AsyncJob` with the same result type."""
@@ -122,6 +277,75 @@ class Job(Generic[T]):
return return
LOOP.run(self._inner.cancel()) LOOP.run(self._inner.cancel())
def refresh(self) -> None:
"""Ask the backend for this job's current state and record.
See :meth:`AsyncJob.refresh`.
"""
if self._inner is None:
return
LOOP.run(self._inner.refresh())
@property
def state(self) -> Optional[str]:
"""The last observed lifecycle state. See :attr:`AsyncJob.state`."""
return self._inner.state if self._inner is not None else "finished"
@property
def job_type(self) -> Optional[str]:
"""The job's type. See :attr:`AsyncJob.job_type`."""
return self._field("job_type")
@property
def creation_ms(self) -> Optional[int]:
"""When the job was created. See :attr:`AsyncJob.creation_ms`."""
return self._field("creation_ms")
@property
def spec(self) -> Optional[Any]:
"""The job's specification. See :attr:`AsyncJob.spec`."""
return self._field("spec")
@property
def result(self) -> Optional[Any]:
"""The job's terminal result. See :attr:`AsyncJob.result`."""
return self._field("result")
@property
def failure(self) -> Optional[JobFailureInfo]:
"""Why the job failed. See :attr:`AsyncJob.failure`."""
return self._field("failure")
@property
def _spec_json(self) -> Optional[str]:
return self._field("_spec_json")
@property
def _result_json(self) -> Optional[str]:
return self._field("_result_json")
def _field(self, name: str) -> Optional[Any]:
return getattr(self._inner, name) if self._inner is not None else None
def events(
self,
*,
limit: Optional[int] = None,
filter: Optional[str] = None,
) -> "pa.Table":
"""This job's recorded lifecycle events.
See :meth:`AsyncJob.events`.
"""
if self._inner is None:
raise NotImplementedError(
"job event history is only available for server-side jobs"
)
return LOOP.run(self._inner.events(limit=limit, filter=filter))
def __repr__(self) -> str:
return _job_repr("Job", self)
def _typed_job( def _typed_job(
inner: "_lancedb.Job", result_decoder: Callable[[str], T] inner: "_lancedb.Job", result_decoder: Callable[[str], T]
+5 -1
View File
@@ -42,6 +42,8 @@ class MaterializedViewDefinition:
"""Cap on the number of rows the view holds.""" """Cap on the number of rows the view holds."""
inputs: List[str] = field(default_factory=list) inputs: List[str] = field(default_factory=list)
"""Source columns the projections and filter read.""" """Source columns the projections and filter read."""
source_namespace: List[str] = field(default_factory=list)
"""Namespace holding the source table; empty is the root namespace."""
def _definition_from_schema( def _definition_from_schema(
@@ -53,7 +55,8 @@ def _definition_from_schema(
raise ValueError(f"Table '{name}' is not a materialized view") raise ValueError(f"Table '{name}' is not a materialized view")
value = json.loads(raw) value = json.loads(raw)
kind = value.get("kind") kind = value.get("kind")
if kind != "select": # "namespaced_select" keeps older readers from resolving the source at root.
if kind not in ("select", "namespaced_select"):
raise NotImplementedError( raise NotImplementedError(
f"materialized view '{name}' is defined by '{kind}', which this " f"materialized view '{name}' is defined by '{kind}', which this "
"version of lancedb cannot refresh" "version of lancedb cannot refresh"
@@ -66,6 +69,7 @@ def _definition_from_schema(
filter=value.get("filter"), filter=value.get("filter"),
limit=value.get("limit"), limit=value.get("limit"),
inputs=value.get("inputs", []), inputs=value.get("inputs", []),
source_namespace=value.get("source_namespace", []),
) )
+35
View File
@@ -12,6 +12,7 @@ from __future__ import annotations
import sys import sys
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union
from uuid import UUID
if sys.version_info >= (3, 12): if sys.version_info >= (3, 12):
from typing import override from typing import override
@@ -48,8 +49,11 @@ from lancedb._lancedb import (
connect_namespace_client as _connect_namespace_client, connect_namespace_client as _connect_namespace_client,
) )
from lancedb.background_loop import LOOP from lancedb.background_loop import LOOP
from lancedb.arrow import AsyncRecordBatchReader
from lancedb.db import AsyncConnection, DBConnection from lancedb.db import AsyncConnection, DBConnection
from lancedb.job import AsyncJob, Job from lancedb.job import AsyncJob, Job
from lancedb.sql import AsyncQuery as AsyncSqlQuery
from lancedb.sql import QueryDescription
from lance_namespace import ( from lance_namespace import (
LanceNamespace, LanceNamespace,
connect as namespace_connect, connect as namespace_connect,
@@ -1447,6 +1451,37 @@ class AsyncLanceNamespaceDBConnection:
namespace_path=namespace_path, page_token=page_token, limit=limit namespace_path=namespace_path, page_token=page_token, limit=limit
) )
async def execute_query(
self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> AsyncRecordBatchReader:
"""Execute SQL when supported by the underlying connection."""
return await self._inner.execute_query(
query,
default_namespace_path=default_namespace_path,
)
async def execute_query_async(
self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> AsyncSqlQuery:
"""Start executing SQL when supported by the underlying connection.
Namespace-backed local connections do not support SQL.
"""
return await self._inner.execute_query_async(
query,
default_namespace_path=default_namespace_path,
)
async def describe_query(self, query_id: UUID) -> QueryDescription:
"""Describe a submitted SQL query when supported."""
return await self._inner.describe_query(query_id)
async def namespace_client(self) -> LanceNamespace: async def namespace_client(self) -> LanceNamespace:
"""Get the namespace client for this connection. """Get the namespace client for this connection.
+47 -9
View File
@@ -109,6 +109,7 @@ def _query_is_plain_scan(query: Query) -> bool:
return ( return (
query.vector is None query.vector is None
and query.full_text_query is None and query.full_text_query is None
and query.take_offsets is None
and not query.postfilter and not query.postfilter
and not query.order_by and not query.order_by
) )
@@ -167,6 +168,12 @@ def _projection_to_scanner_kwargs(columns: QueryProjection) -> Dict[str, Any]:
return {"columns": projection} return {"columns": projection}
def _query_request_projection(req: "PyQueryRequest") -> QueryProjection:
if req.select_source_columns is not None:
return req.select_source_columns
return req.select
def _scanner_kwargs_for_query( def _scanner_kwargs_for_query(
query: Query, query: Query,
blob_mode: BlobMode, blob_mode: BlobMode,
@@ -375,6 +382,13 @@ class FullTextOperator(str, Enum):
OR = "OR" OR = "OR"
class DocumentGranularity(str, Enum):
"""The unit treated as one full-text-search document."""
ROW = "row"
LIST_ELEMENT = "list_element"
class Occur(str, Enum): class Occur(str, Enum):
SHOULD = "SHOULD" SHOULD = "SHOULD"
MUST = "MUST" MUST = "MUST"
@@ -478,6 +492,10 @@ class MatchQuery(FullTextQuery):
prefix_length : int, optional prefix_length : int, optional
The number of beginning characters being unchanged for fuzzy matching. The number of beginning characters being unchanged for fuzzy matching.
This is useful to achieve prefix matching. This is useful to achieve prefix matching.
document_granularity : DocumentGranularity, optional
Explicitly select row or deepest-list-element documents. If omitted,
the indexed granularity is inferred. When both granularities are indexed
for the field, this must be specified. With no index, row granularity is used.
""" """
query: str query: str
@@ -487,6 +505,9 @@ class MatchQuery(FullTextQuery):
max_expansions: int = pydantic.Field(50, kw_only=True) max_expansions: int = pydantic.Field(50, kw_only=True)
operator: FullTextOperator = pydantic.Field(FullTextOperator.OR, kw_only=True) operator: FullTextOperator = pydantic.Field(FullTextOperator.OR, kw_only=True)
prefix_length: int = pydantic.Field(0, kw_only=True) prefix_length: int = pydantic.Field(0, kw_only=True)
document_granularity: Optional[DocumentGranularity] = pydantic.Field(
None, kw_only=True
)
def query_type(self) -> FullTextQueryType: def query_type(self) -> FullTextQueryType:
return FullTextQueryType.MATCH return FullTextQueryType.MATCH
@@ -503,11 +524,20 @@ class PhraseQuery(FullTextQuery):
The query string to match against. The query string to match against.
column : str column : str
The name of the column to match against. The name of the column to match against.
slop : int, default 0
The maximum number of intervening positions permitted in the phrase.
document_granularity : DocumentGranularity, optional
Explicitly select row or deepest-list-element documents. If omitted,
the indexed granularity is inferred. When both granularities are indexed
for the field, this must be specified. With no index, row granularity is used.
""" """
query: str query: str
column: str column: str
slop: int = pydantic.Field(0, kw_only=True) slop: int = pydantic.Field(0, kw_only=True)
document_granularity: Optional[DocumentGranularity] = pydantic.Field(
None, kw_only=True
)
def query_type(self) -> FullTextQueryType: def query_type(self) -> FullTextQueryType:
return FullTextQueryType.MATCH_PHRASE return FullTextQueryType.MATCH_PHRASE
@@ -775,6 +805,10 @@ class Query(pydantic.BaseModel):
# offset to start fetching results from # offset to start fetching results from
offset: Optional[int] = None offset: Optional[int] = None
# Dataset offsets whose duplicate occurrences must be restored after lookup.
# This is populated when a take query is converted to this serializable form.
take_offsets: Optional[List[int]] = None
# if true, will only search the indexed data # if true, will only search the indexed data
fast_search: Optional[bool] = None fast_search: Optional[bool] = None
@@ -796,6 +830,7 @@ class Query(pydantic.BaseModel):
query = cls() query = cls()
query.limit = req.limit query.limit = req.limit
query.offset = req.offset query.offset = req.offset
query.take_offsets = req.take_offsets
query.filter = req.filter query.filter = req.filter
query.full_text_query = req.full_text_search query.full_text_query = req.full_text_search
query.columns = req.select query.columns = req.select
@@ -2776,15 +2811,16 @@ class AsyncQueryBase(object):
req = self._inner.to_query_request() req = self._inner.to_query_request()
schema = await self._table.schema() schema = await self._table.schema()
projection = _query_request_projection(req)
self._blob_auto_row_id = blob_auto_row_id_for_scan( self._blob_auto_row_id = blob_auto_row_id_for_scan(
schema, schema,
req.select, projection,
with_row_id=self._with_row_id, with_row_id=self._with_row_id,
) )
if not self._blob_auto_row_id: if not self._blob_auto_row_id:
self._blob_paths = () self._blob_paths = ()
return return
self._blob_paths = tuple(blob_v2_projection_sources(schema, req.select).keys()) self._blob_paths = tuple(blob_v2_projection_sources(schema, projection).keys())
self._inner.with_row_id() self._inner.with_row_id()
def select(self, columns: Union[List[str], dict[str, str]]) -> Self: def select(self, columns: Union[List[str], dict[str, str]]) -> Self:
@@ -3378,9 +3414,10 @@ class AsyncQuery(AsyncStandardQuery):
pass in multiple vectors. When multiple vectors are passed in, if the vector pass in multiple vectors. When multiple vectors are passed in, if the vector
column is with multivector type, then the vectors will be treated as a single column is with multivector type, then the vectors will be treated as a single
query. Or the vectors will be treated as multiple queries, this can be useful query. Or the vectors will be treated as multiple queries, this can be useful
if you want to find the nearest vectors to multiple query vectors. if you want to find the nearest vectors to multiple query vectors. Flat
This is not expected to be faster than making multiple queries concurrently; searches share one table scan across the query vectors, avoiding the scan
it is just a convenience method. If multiple vectors are passed in then and memory amplification of making multiple queries concurrently. If
multiple vectors are passed in then
an additional column `query_index` will be added to the results. This column an additional column `query_index` will be added to the results. This column
will contain the index of the query vector that the result is nearest to. will contain the index of the query vector that the result is nearest to.
""" """
@@ -3509,8 +3546,8 @@ class AsyncFTSQuery(AsyncStandardQuery):
Typically, a single vector is passed in as the query. However, you can also Typically, a single vector is passed in as the query. However, you can also
pass in multiple vectors. This can be useful if you want to find the nearest pass in multiple vectors. This can be useful if you want to find the nearest
vectors to multiple query vectors. This is not expected to be faster than vectors to multiple query vectors. Flat searches share one table scan across
making multiple queries concurrently; it is just a convenience method. the query vectors instead of issuing concurrent full scans.
If multiple vectors are passed in then an additional column `query_index` If multiple vectors are passed in then an additional column `query_index`
will be added to the results. This column will contain the index of the will be added to the results. This column will contain the index of the
query vector that the result is nearest to. query vector that the result is nearest to.
@@ -3870,14 +3907,15 @@ class AsyncHybridQuery(AsyncStandardQuery, AsyncVectorQueryBase):
blob_paths: tuple[str, ...] = () blob_paths: tuple[str, ...] = ()
if self._table is not None: if self._table is not None:
schema = await self._table.schema() schema = await self._table.schema()
projection = _query_request_projection(req)
blob_auto_row_id = blob_auto_row_id_for_scan( blob_auto_row_id = blob_auto_row_id_for_scan(
schema, schema,
req.select, projection,
with_row_id=self._with_row_id, with_row_id=self._with_row_id,
) )
if blob_auto_row_id: if blob_auto_row_id:
blob_paths = tuple( blob_paths = tuple(
blob_v2_projection_sources(schema, req.select).keys() blob_v2_projection_sources(schema, projection).keys()
) )
self._blob_auto_row_id = blob_auto_row_id self._blob_auto_row_id = blob_auto_row_id
self._blob_paths = blob_paths self._blob_paths = blob_paths
+86 -23
View File
@@ -7,8 +7,18 @@ import json
import logging import logging
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
import sys import sys
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Optional, Union from typing import (
TYPE_CHECKING,
Any,
Dict,
Iterable,
List,
Optional,
Sequence,
Union,
)
from urllib.parse import urlparse from urllib.parse import urlparse
from uuid import UUID
import warnings import warnings
if sys.version_info >= (3, 12): if sys.version_info >= (3, 12):
@@ -25,10 +35,13 @@ from ..common import DATA
from ..db import DBConnection, LOOP from ..db import DBConnection, LOOP
from ..functions import FunctionVersion, UdfDefinition from ..functions import FunctionVersion, UdfDefinition
from ..job import AsyncJob, Job from ..job import AsyncJob, Job
from ..sql import Query as SqlQuery
from ..sql import QueryDescription
from ..materialized_view import MaterializedView, SelectArg from ..materialized_view import MaterializedView, SelectArg
from ..secrets import EnvVarSecret, SecretInfo
if TYPE_CHECKING: if TYPE_CHECKING:
from .._lancedb import JobDescription, JobInfo from .._lancedb import JobInfo
from ..embeddings import EmbeddingFunctionConfig from ..embeddings import EmbeddingFunctionConfig
from lance_namespace import ( from lance_namespace import (
LanceNamespace, LanceNamespace,
@@ -116,6 +129,7 @@ class RemoteDBConnection(DBConnection):
read_timeout: Optional[float] = None, read_timeout: Optional[float] = None,
storage_options: Optional[Dict[str, str]] = None, storage_options: Optional[Dict[str, str]] = None,
read_consistency_interval: Optional[timedelta] = None, read_consistency_interval: Optional[timedelta] = None,
sql_host_override: Optional[str] = None,
): ):
"""Connect to a remote LanceDB database.""" """Connect to a remote LanceDB database."""
if isinstance(client_config, dict): if isinstance(client_config, dict):
@@ -161,6 +175,7 @@ class RemoteDBConnection(DBConnection):
self.api_key = api_key self.api_key = api_key
self.region = region self.region = region
self.host_override = host_override self.host_override = host_override
self.sql_host_override = sql_host_override
self.storage_options = storage_options self.storage_options = storage_options
self.db_name = parsed.netloc self.db_name = parsed.netloc
@@ -175,6 +190,7 @@ class RemoteDBConnection(DBConnection):
api_key=api_key, api_key=api_key,
region=region, region=region,
host_override=host_override, host_override=host_override,
sql_host_override=sql_host_override,
client_config=client_config, client_config=client_config,
storage_options=storage_options, storage_options=storage_options,
read_consistency_interval=read_consistency_interval, read_consistency_interval=read_consistency_interval,
@@ -193,6 +209,7 @@ class RemoteDBConnection(DBConnection):
"api_key": self.api_key, "api_key": self.api_key,
"region": self.region, "region": self.region,
"host_override": self.host_override, "host_override": self.host_override,
"sql_host_override": self.sql_host_override,
"client_config": _client_config_to_dict(self.client_config), "client_config": _client_config_to_dict(self.client_config),
"storage_options": self.storage_options, "storage_options": self.storage_options,
} }
@@ -732,36 +749,59 @@ class RemoteDBConnection(DBConnection):
) )
@override @override
def job(self, job_id: str) -> Job: def open_job(self, job_id: str) -> Job:
"""A [Job][lancedb.job.Job] handle for a server-side job by id. """Open a server-side job by id. See
[DBConnection.open_job][lancedb.db.DBConnection.open_job].
The handle is constructed without a server round trip; an unknown id
surfaces when the handle is used. Dropping the handle has no effect
on the job itself.
""" """
return Job(self._conn.job(job_id)) return Job(LOOP.run(self._conn.open_job(job_id)))
@override @override
def create_function_async(self, definition: UdfDefinition) -> Job[FunctionVersion]: def create_function_async(
return Job(LOOP.run(self._conn.create_function_async(definition))) self,
definition: UdfDefinition,
*,
secrets: Optional[Sequence[EnvVarSecret]] = None,
) -> Job[FunctionVersion]:
job = LOOP.run(self._conn.create_function_async(definition, secrets=secrets))
return Job(job)
@override @override
def get_function(self, name: str, *, version: str) -> FunctionVersion: def get_function(self, name: str, *, version: str) -> FunctionVersion:
return LOOP.run(self._conn.get_function(name, version=version)) return LOOP.run(self._conn.get_function(name, version=version))
@override
def list_functions(self) -> List[FunctionVersion]:
return LOOP.run(self._conn.list_functions())
@override
def drop_function(self, name: str, *, version: str) -> bool:
return LOOP.run(self._conn.drop_function(name, version=version))
@override
def create_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.create_secret(name, value))
@override
def alter_secret(self, name: str, value: str) -> None:
LOOP.run(self._conn.alter_secret(name, value))
@override
def describe_secret(self, name: str) -> SecretInfo:
return LOOP.run(self._conn.describe_secret(name))
@override
def list_secrets(self) -> List[str]:
return LOOP.run(self._conn.list_secrets())
@override
def drop_secret(self, name: str) -> None:
LOOP.run(self._conn.drop_secret(name))
@override @override
def list_jobs(self) -> List["JobInfo"]: def list_jobs(self) -> List["JobInfo"]:
"""List server-side jobs across the database's tables.""" """List server-side jobs across the database's tables."""
return LOOP.run(self._conn.list_jobs()) return LOOP.run(self._conn.list_jobs())
@override
def get_job(self, job_id: str) -> Optional["JobDescription"]:
"""Describe a single server-side job by id.
Returns None when the server has no such job.
"""
return LOOP.run(self._conn.get_job(job_id))
@override @override
def cancel_job(self, job_id: str) -> bool: def cancel_job(self, job_id: str) -> bool:
"""Request cancellation of a server-side job by id. """Request cancellation of a server-side job by id.
@@ -773,12 +813,35 @@ class RemoteDBConnection(DBConnection):
return LOOP.run(self._conn.cancel_job(job_id)) return LOOP.run(self._conn.cancel_job(job_id))
@override @override
def job_history(self, job_id: Optional[str] = None) -> List[pa.RecordBatch]: def execute_query_async(
"""The lifecycle event history of a server-side job, as Arrow batches. self,
query: str,
*,
default_namespace_path: Optional[List[str]] = None,
) -> SqlQuery:
"""Start executing SQL through this remote connection.
Lists history across all jobs when `job_id` is None. Unqualified tables use this connection's database and the
``["public"]`` namespace by default. Fully qualified table names may
reference other databases available to the same deployment.
""" """
return LOOP.run(self._conn.job_history(job_id)) return SqlQuery(
LOOP.run(
self._conn.execute_query_async(
query,
default_namespace_path=default_namespace_path,
)
)
)
@override
def describe_query(self, query_id: UUID) -> QueryDescription:
"""Describe a submitted SQL query by its connection-scoped id."""
return LOOP.run(
self._conn.describe_query(
query_id,
)
)
@override @override
def namespace_client(self) -> LanceNamespace: def namespace_client(self) -> LanceNamespace:
+4 -1
View File
@@ -177,4 +177,7 @@ class OAuthProvider(HeaderProvider):
if not self._current_token: if not self._current_token:
raise RuntimeError("Failed to obtain OAuth token") raise RuntimeError("Failed to obtain OAuth token")
return {"Authorization": f"Bearer {self._current_token}"} return {
"Authorization": f"Bearer {self._current_token}",
"x-lancedb-credential-type": "oidc",
}
+20 -5
View File
@@ -36,6 +36,7 @@ from lancedb._lancedb import (
UpdateResult, UpdateResult,
) )
from lancedb.embeddings.base import EmbeddingFunctionConfig from lancedb.embeddings.base import EmbeddingFunctionConfig
from lancedb.expr import Expr
from lancedb.index import ( from lancedb.index import (
FTS, FTS,
BTree, BTree,
@@ -61,11 +62,20 @@ from lancedb.table import _normalize_progress
from ..query import ( from ..query import (
AnalyzePlanDistributedMetrics, AnalyzePlanDistributedMetrics,
DocumentGranularity,
LanceQueryBuilder, LanceQueryBuilder,
LanceTakeQueryBuilder, LanceTakeQueryBuilder,
LanceVectorQueryBuilder, 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 from ..types import BaseTokenizerType
@@ -349,6 +359,7 @@ class RemoteTable(Table):
ngram_max_length: int = 3, ngram_max_length: int = 3,
prefix_only: bool = False, prefix_only: bool = False,
block_size: int = 128, block_size: int = 128,
document_granularity: DocumentGranularity = DocumentGranularity.ROW,
name: Optional[str] = None, name: Optional[str] = None,
): ):
"""Create a full-text search index on a column. """Create a full-text search index on a column.
@@ -371,6 +382,7 @@ class RemoteTable(Table):
ngram_max_length=ngram_max_length, ngram_max_length=ngram_max_length,
prefix_only=prefix_only, prefix_only=prefix_only,
block_size=block_size, block_size=block_size,
document_granularity=document_granularity,
) )
LOOP.run( LOOP.run(
self._table.create_index( self._table.create_index(
@@ -536,6 +548,7 @@ class RemoteTable(Table):
LOOP.run( LOOP.run(
self._table.create_index( self._table.create_index(
column, column,
replace=replace,
config=config, config=config,
wait_timeout=wait_timeout, wait_timeout=wait_timeout,
name=name, name=name,
@@ -860,7 +873,7 @@ class RemoteTable(Table):
def update( def update(
self, self,
where: Optional[str] = None, where: Optional[Union[str, Expr]] = None,
values: Optional[dict] = None, values: Optional[dict] = None,
*, *,
values_sql: Optional[Dict[str, str]] = None, values_sql: Optional[Dict[str, str]] = None,
@@ -871,9 +884,11 @@ class RemoteTable(Table):
Parameters Parameters
---------- ----------
where: str, optional where: str or [Expr][lancedb.expr.Expr], optional
The SQL where clause to use when updating rows. For example, 'x = 2' The filter condition. Can be a SQL string or a type-safe
or 'x IN (1, 2, 3)'. The filter must not be empty, or it will error. [Expr][lancedb.expr.Expr] built with [col][lancedb.expr.col] and
[lit][lancedb.expr.lit]. The filter must not be empty, or it will
error.
values: dict, optional values: dict, optional
The values to update. The keys are the column names and the values The values to update. The keys are the column names and the values
are the values to set. are the values to set.
+101 -34
View File
@@ -4,30 +4,34 @@
"""Schema helpers for Lance blob columns.""" """Schema helpers for Lance blob columns."""
import importlib
from typing import TYPE_CHECKING
import pyarrow as pa import pyarrow as pa
import pyarrow.ipc
if TYPE_CHECKING:
from lance.blob import BlobType as BlobType
_BLOB_EXTENSION_NAME = "lance.blob.v2" _BLOB_EXTENSION_NAME = "lance.blob.v2"
_BLOB_V1_KEY = "lance-encoding:blob" _BLOB_V1_KEY = "lance-encoding:blob"
_ARROW_EXT_NAME_KEY = "ARROW:extension:name" _ARROW_EXT_NAME_KEY = "ARROW:extension:name"
_BLOB_V2_STORAGE_TYPE = pa.struct(
[
pa.field("data", pa.large_binary(), nullable=True),
pa.field("uri", pa.utf8(), nullable=True),
pa.field("position", pa.uint64(), nullable=True),
pa.field("size", pa.uint64(), nullable=True),
]
)
_resolved_blob_type = None
class BlobType(pa.ExtensionType): class _FallbackBlobType(pa.ExtensionType):
"""PyArrow extension type for a Lance blob v2 column. """lance.blob.v2 extension type used when pylance is not installed."""
Queries return descriptors; call :meth:`~lancedb.table.Table.fetch_blob_files`
for lazy reads or :meth:`~lancedb.table.Table.fetch_blobs` for eager bytes.
"""
def __init__(self) -> None: def __init__(self) -> None:
storage_type = pa.struct( pa.ExtensionType.__init__(self, _BLOB_V2_STORAGE_TYPE, _BLOB_EXTENSION_NAME)
[
pa.field("data", pa.large_binary(), nullable=True),
pa.field("uri", pa.utf8(), nullable=True),
pa.field("position", pa.uint64(), nullable=True),
pa.field("size", pa.uint64(), nullable=True),
]
)
super().__init__(storage_type, _BLOB_EXTENSION_NAME)
def __arrow_ext_serialize__(self) -> bytes: def __arrow_ext_serialize__(self) -> bytes:
return b"" return b""
@@ -35,23 +39,16 @@ class BlobType(pa.ExtensionType):
@classmethod @classmethod
def __arrow_ext_deserialize__( def __arrow_ext_deserialize__(
cls, storage_type: pa.DataType, serialized: bytes cls, storage_type: pa.DataType, serialized: bytes
) -> "BlobType": ) -> "_FallbackBlobType":
return cls() return cls()
def __reduce__(self): def __reduce__(self):
# Ensure pickle round-trips on older pyarrow (apache/arrow#35599).
return type(self).__arrow_ext_deserialize__, ( return type(self).__arrow_ext_deserialize__, (
self.storage_type, self.storage_type,
self.__arrow_ext_serialize__(), self.__arrow_ext_serialize__(),
) )
try:
pa.register_extension_type(BlobType()) # type: ignore[arg-type]
except pa.ArrowKeyError:
pass
def _metadata_value(metadata: dict, key: str): def _metadata_value(metadata: dict, key: str):
return metadata.get(key.encode()) or metadata.get(key) return metadata.get(key.encode()) or metadata.get(key)
@@ -92,43 +89,105 @@ def is_blob_like_field(field: pa.Field) -> bool:
return is_blob_v2_field(field) or _metadata_marks_legacy_blob(field.metadata or {}) return is_blob_v2_field(field) or _metadata_marks_legacy_blob(field.metadata or {})
def _collect_blob_paths(schema: pa.Schema, is_blob) -> list[str]: def _collect_blob_paths(schema: pa.Schema, is_blob) -> list[tuple[str, bool]]:
paths: list[str] = [] """Walk the schema and return (path, has_list_ancestor) for each blob field."""
paths: list[tuple[str, bool]] = []
def walk(fields, prefix: str) -> None: def walk(fields, prefix: str, has_list_ancestor: bool) -> None:
for field in fields: for field in fields:
path = f"{prefix}.{field.name}" if prefix else field.name path = f"{prefix}.{field.name}" if prefix else field.name
if is_blob(field): if is_blob(field):
paths.append(path) paths.append((path, has_list_ancestor))
elif pa.types.is_struct(field.type): elif pa.types.is_struct(field.type):
walk(field.type, path) walk(field.type, path, has_list_ancestor)
elif ( elif (
pa.types.is_list(field.type) pa.types.is_list(field.type)
or pa.types.is_large_list(field.type) or pa.types.is_large_list(field.type)
or pa.types.is_fixed_size_list(field.type) or pa.types.is_fixed_size_list(field.type)
): ):
walk([field.type.value_field], path) walk([field.type.value_field], path, True)
walk(schema, "") walk(schema, "", False)
return paths return paths
def blob_column_paths(schema: pa.Schema) -> list[str]: def blob_column_paths(schema: pa.Schema) -> list[str]:
"""Dotted paths of blob-like columns (v2 extension or legacy metadata).""" """Dotted paths of blob-like columns (v2 extension or legacy metadata)."""
return _collect_blob_paths(schema, is_blob_like_field) return [path for path, _ in _collect_blob_paths(schema, is_blob_like_field)]
def blob_v2_column_paths(schema: pa.Schema) -> list[str]: def blob_v2_column_paths(schema: pa.Schema) -> list[str]:
return _collect_blob_paths(schema, is_blob_v2_field) return [path for path, _ in _collect_blob_paths(schema, is_blob_v2_field)]
def row_addressable_blob_v2_paths(schema: pa.Schema) -> list[str]:
"""Blob v2 paths with one blob addressable by table row id.
``fetch_blobs`` and the descriptor row-id ride-along address one blob per
row, so a blob inside a list container has no row-id slot and no fetch
path. Those columns still store and query as raw descriptors.
"""
return [
path
for path, has_list_ancestor in _collect_blob_paths(schema, is_blob_v2_field)
if not has_list_ancestor
]
def schema_has_blob_field(schema: pa.Schema) -> bool: def schema_has_blob_field(schema: pa.Schema) -> bool:
return bool(blob_column_paths(schema)) return bool(blob_column_paths(schema))
def _deserialize_registered_type(extension_type: pa.ExtensionType) -> pa.DataType:
"""Return the type Arrow reconstructs for this extension name."""
schema = pa.schema([pa.field("value", extension_type)])
restored = pa.ipc.read_schema(schema.serialize())
return restored.field("value").type
def _resolve_blob_type():
"""Return the BlobType class this process should use.
pylance's class when it owns the lance.blob.v2 registry entry,
otherwise LanceDB's fallback. A different registered class is an error.
"""
global _resolved_blob_type
if _resolved_blob_type is not None:
return _resolved_blob_type
try:
blob_module = importlib.import_module("lance.blob")
except ModuleNotFoundError as err:
if err.name not in ("lance", "lance.blob"):
raise
else:
blob_type = getattr(blob_module, "BlobType", None)
if blob_type is not None:
registered_type = _deserialize_registered_type(blob_type())
if type(registered_type) is not blob_type:
registered_cls = type(registered_type)
raise ValueError(
"lance.blob.v2 is already registered by "
f"{registered_cls.__module__}.{registered_cls.__qualname__}"
)
_resolved_blob_type = blob_type
return blob_type
try:
pa.register_extension_type(_FallbackBlobType()) # type: ignore[arg-type]
except pa.ArrowKeyError as err:
raise ValueError(
"lance.blob.v2 is already registered by another extension class"
) from err
_resolved_blob_type = _FallbackBlobType
return _resolved_blob_type
def blob(name: str, nullable: bool = True) -> pa.Field: def blob(name: str, nullable: bool = True) -> pa.Field:
"""Create a Lance blob v2 column field.""" """Create a Lance blob v2 column field.
return pa.field(name, BlobType(), nullable=nullable)
When pylance is installed this is ``lance.blob.BlobType``.
"""
blob_type = _resolve_blob_type()
return pa.field(name, blob_type(), nullable=nullable)
def vector(dimension: int, value_type: pa.DataType = pa.float32()) -> pa.DataType: def vector(dimension: int, value_type: pa.DataType = pa.float32()) -> pa.DataType:
@@ -155,3 +214,11 @@ def vector(dimension: int, value_type: pa.DataType = pa.float32()) -> pa.DataTyp
... ]) ... ])
""" """
return pa.list_(value_type, dimension) return pa.list_(value_type, dimension)
def __getattr__(name: str):
if name == "BlobType":
blob_type = _resolve_blob_type()
globals()["BlobType"] = blob_type
return blob_type
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
+169
View File
@@ -0,0 +1,169 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Named Secrets, and the bindings that deliver them to Functions.
A Secret is a database-scoped named credential. Nothing in this module holds a
value: :class:`EnvVarSecret` names one and says which environment variable it
should arrive in, and the value is resolved by the remote service when a
Function bound to it runs. No API returns a stored credential, by construction
rather than by policy -- there is no code path that could.
"""
from __future__ import annotations
import re
# The same characters LanceDB already admits in a namespace or table name, and
# no positional rule on top of them: a segment may begin with `_`, `-` or `.`
# today, so anything narrower would put Secrets out of reach inside namespaces
# that already exist. Matches the service, which admits the same set.
_SECRET_NAME = re.compile(r"^[A-Za-z0-9_.-]{1,255}$")
_ENV_VARIABLE = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
def validate_secret_name(name: str) -> str:
"""Check a Secret name locally and return it unchanged."""
if not isinstance(name, str):
raise TypeError(f"Secret name must be a string, not {type(name).__name__}")
if not _SECRET_NAME.fullmatch(name):
raise ValueError(f"invalid Secret name: {name!r}")
return name
def validate_env_variable(name: str) -> str:
"""Check an environment variable name locally and return it unchanged."""
if not isinstance(name, str):
raise TypeError(
f"environment variable name must be a string, not {type(name).__name__}"
)
if not _ENV_VARIABLE.fullmatch(name):
raise ValueError(f"invalid environment variable name: {name!r}")
return name
class EnvVarSecret:
"""A Secret bound to the environment variable a Function's library reads.
Pass these in the ``secrets`` sequence of
[DBConnection.create_function][lancedb.db.DBConnection.create_function]. The
Function's source is unchanged by binding: it reads ``OPENAI_API_KEY`` the
way it always did, and the binding is what puts a value there.
This is a local value. Constructing it contacts no server, so it always
succeeds and says nothing about whether the Secret exists; that is checked
at registration, where a mistyped Secret name surfaces as a clear "does not
exist" naming both the Secret and the variable bound to it. A mistyped
*variable* name cannot be caught anywhere -- nothing knows which variables a
Function reads -- so it surfaces on the first rows instead.
The type exists so a credential cannot be passed by accident. A bare string
in the same position is a plausible-looking mistake with the opposite
meaning, and it reads identically in a diff.
Parameters
----------
secret : str
The Secret's database-scoped name.
env_variable : str
The environment variable the Function reads it from.
Examples
--------
>>> from lancedb import EnvVarSecret
>>> binding = EnvVarSecret(secret="openai-prod", env_variable="OPENAI_API_KEY")
>>> binding.secret, binding.env_variable
('openai-prod', 'OPENAI_API_KEY')
"""
__slots__ = ("_secret", "_env_variable")
def __init__(self, secret: str, env_variable: str):
self._secret = validate_secret_name(secret)
self._env_variable = validate_env_variable(env_variable)
@property
def secret(self) -> str:
"""The Secret's database-scoped name."""
return self._secret
@property
def env_variable(self) -> str:
"""The environment variable the value is delivered in."""
return self._env_variable
def __repr__(self) -> str:
return (
f"EnvVarSecret(secret={self._secret!r}, "
f"env_variable={self._env_variable!r})"
)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, EnvVarSecret)
and other._secret == self._secret
and other._env_variable == self._env_variable
)
def __hash__(self) -> int:
return hash((EnvVarSecret, self._secret, self._env_variable))
class SecretInfo:
"""What a database records about a Secret. Never its value.
Returned by
[DBConnection.describe_secret][lancedb.db.DBConnection.describe_secret].
"""
__slots__ = ("_name", "_created_at", "_updated_at")
def __init__(self, name: str, created_at: str, updated_at: str):
self._name = name
self._created_at = created_at
self._updated_at = updated_at
@property
def name(self) -> str:
"""The Secret's database-scoped name."""
return self._name
@property
def created_at(self) -> str:
"""When the Secret was created, as an RFC 3339 timestamp."""
return self._created_at
@property
def updated_at(self) -> str:
"""When the Secret's value was last rotated, as an RFC 3339 timestamp."""
return self._updated_at
@classmethod
def from_json(cls, value: dict) -> "SecretInfo":
return cls(
name=value["name"],
created_at=value["created_at"],
updated_at=value["updated_at"],
)
def __repr__(self) -> str:
return (
f"SecretInfo(name={self._name!r}, created_at={self._created_at!r}, "
f"updated_at={self._updated_at!r})"
)
def __eq__(self, other: object) -> bool:
return (
isinstance(other, SecretInfo)
and other._name == self._name
and other._created_at == self._created_at
and other._updated_at == self._updated_at
)
__all__ = [
"EnvVarSecret",
"SecretInfo",
"validate_env_variable",
"validate_secret_name",
]
+88
View File
@@ -0,0 +1,88 @@
# SPDX-License-Identifier: Apache-2.0
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
"""Handles to SQL queries running on a remote database."""
from uuid import UUID
import pyarrow as pa
from lancedb.background_loop import LOOP
from . import _lancedb
from .arrow import AsyncRecordBatchReader
QueryDescription = _lancedb.QueryDescription
class AsyncQuery:
"""A handle to a submitted SQL query on an asynchronous connection."""
def __init__(self, inner: "_lancedb.SqlQuery"):
self._inner = inner
@property
def id(self) -> UUID:
"""The stable identifier scoped to the connection that submitted it."""
return self._inner.id
async def describe(self) -> QueryDescription:
"""Get a point-in-time description of the query."""
return await self._inner.describe()
async def reader(self) -> AsyncRecordBatchReader:
"""Wait for the initial result stream and return its Arrow reader.
Results are single-consumer. Calling this method more than once on the
same query raises an error. Later batches are streamed as they become
available without waiting for the full query to finish.
"""
return AsyncRecordBatchReader(await self._inner.reader())
async def cancel(self) -> None:
"""Request cancellation of the query."""
await self._inner.cancel()
class Query:
"""Synchronous counterpart of :class:`AsyncQuery`."""
def __init__(self, inner: AsyncQuery):
self._inner = inner
@property
def id(self) -> UUID:
"""The stable identifier scoped to the connection that submitted it."""
return self._inner.id
def describe(self) -> QueryDescription:
"""Get a point-in-time description of the query."""
return LOOP.run(self._inner.describe())
def reader(self) -> pa.RecordBatchReader:
"""Wait for the initial result stream and return a blocking reader.
Results are single-consumer. Calling this method more than once on the
same query raises an error. Later batches block only until they become
available, without waiting for the full query to finish.
"""
reader = LOOP.run(self._inner.reader())
def next_batch():
try:
return LOOP.run(reader.__anext__())
except StopAsyncIteration:
return None
def batches():
while (batch := next_batch()) is not None:
yield batch
return pa.RecordBatchReader.from_batches(reader.schema, batches())
def cancel(self) -> None:
"""Request cancellation of the query."""
LOOP.run(self._inner.cancel())
__all__ = ["AsyncQuery", "Query", "QueryDescription"]
+336 -86
View File
@@ -85,6 +85,7 @@ from .query import (
AsyncQuery, AsyncQuery,
AsyncTakeQuery, AsyncTakeQuery,
AsyncVectorQuery, AsyncVectorQuery,
DocumentGranularity,
FullTextQuery, FullTextQuery,
LanceEmptyQueryBuilder, LanceEmptyQueryBuilder,
LanceFtsQueryBuilder, LanceFtsQueryBuilder,
@@ -103,7 +104,12 @@ from .util import (
value_to_sql, value_to_sql,
) )
from .index import lang_mapping from .index import lang_mapping
from .schema import blob_v2_column_paths, schema_has_blob_field from .schema import (
blob_v2_column_paths,
is_blob_v2_field,
row_addressable_blob_v2_paths,
schema_has_blob_field,
)
def _should_push_down_query_table( def _should_push_down_query_table(
@@ -425,6 +431,7 @@ def _cast_to_target_schema(
def gen(): def gen():
for batch in reader: for batch in reader:
batch = _coerce_blob_write_columns(batch, reordered_schema)
# Table but not RecordBatch has cast. # Table but not RecordBatch has cast.
cast_batches = ( cast_batches = (
pa.Table.from_batches([batch]).cast(reordered_schema).to_batches() pa.Table.from_batches([batch]).cast(reordered_schema).to_batches()
@@ -437,6 +444,166 @@ def _cast_to_target_schema(
return pa.RecordBatchReader.from_batches(reordered_schema, gen()) return pa.RecordBatchReader.from_batches(reordered_schema, gen())
def _coerce_blob_write_columns(
batch: pa.RecordBatch, target_schema: pa.Schema
) -> pa.RecordBatch:
"""Materialize blob storage structs before the stream leaves Python.
merge_insert requires its source reader to already match the table's
physical schema. Unlike add and insert, it does not pass through
LanceDB's Rust blob coercion, so preserving binary input here would
reach Lance as binary and fail the schema check.
"""
columns = []
fields = []
changed = False
for field, column in zip(batch.schema, batch.columns):
target_field = target_schema.field(field.name)
coerced = _coerce_blob_value(column, target_field)
if coerced is not column:
column = coerced
field = pa.field(
field.name,
coerced.type,
field.nullable,
target_field.metadata,
)
changed = True
columns.append(column)
fields.append(field)
if not changed:
return batch
return pa.RecordBatch.from_arrays(
columns, schema=pa.schema(fields, metadata=batch.schema.metadata)
)
def _coerce_blob_value(column: pa.Array, target_field: pa.Field) -> pa.Array:
if is_blob_v2_field(target_field) and _can_coerce_to_blob(column.type):
return _coerce_value_to_blob(column, target_field)
target_type = target_field.type
if pa.types.is_struct(target_type) and pa.types.is_struct(column.type):
children = []
fields = []
changed = False
for source_field in column.type:
source_column = column.field(source_field.name)
nested_target = next(
(field for field in target_type if field.name == source_field.name),
None,
)
if nested_target is None:
children.append(source_column)
fields.append(source_field)
continue
coerced = _coerce_blob_value(source_column, nested_target)
if coerced is not source_column:
changed = True
child_array, child_type = _physical_array_and_type(coerced)
children.append(child_array)
fields.append(
pa.field(
source_field.name,
child_type,
source_field.nullable,
nested_target.metadata,
)
)
if not changed:
return column
return pa.StructArray.from_arrays(
children,
fields=fields,
mask=column.is_null() if column.null_count else None,
)
if _is_list_like(target_type) and _is_list_like(column.type):
return _coerce_blob_list_values(column, target_type.value_field)
return column
def _coerce_blob_list_values(
column: pa.Array, target_value_field: pa.Field
) -> pa.Array:
"""Coerce blob values inside a list column, preserving offsets and nulls.
Works on the raw child values window instead of ``pc.list_flatten`` because
flatten drops values spanned by null slots, which would misalign offsets.
"""
mask = column.is_null() if column.null_count else None
if pa.types.is_fixed_size_list(column.type):
list_size = column.type.list_size
values = column.values.slice(column.offset * list_size, len(column) * list_size)
coerced = _coerce_blob_value(values, target_value_field)
if coerced is values:
return column
physical_values, _ = _physical_array_and_type(coerced)
return pa.FixedSizeListArray.from_arrays(physical_values, list_size, mask=mask)
offsets = column.offsets
first_offset = offsets[0].as_py()
values = column.values.slice(
first_offset,
offsets[-1].as_py() - first_offset,
)
coerced = _coerce_blob_value(values, target_value_field)
if coerced is values:
return column
physical_values, _ = _physical_array_and_type(coerced)
if first_offset:
offsets = pc.subtract(offsets, pa.scalar(first_offset, offsets.type))
if pa.types.is_large_list(column.type):
return pa.LargeListArray.from_arrays(offsets, physical_values, mask=mask)
return pa.ListArray.from_arrays(offsets, physical_values, mask=mask)
def _coerce_value_to_blob(values: pa.Array, target_field: pa.Field) -> pa.Array:
if pa.types.is_null(values.type):
data = pa.nulls(len(values), type=pa.large_binary())
elif pa.types.is_large_binary(values.type):
data = values
else:
data = values.cast(pa.large_binary())
length = len(values)
storage_type = target_field.type
if isinstance(storage_type, pa.ExtensionType):
storage_type = storage_type.storage_type
storage_fields = list(storage_type)
children = []
for storage_field in storage_fields:
if storage_field.name == "data":
children.append(data)
else:
children.append(pa.nulls(length, type=storage_field.type))
storage = pa.StructArray.from_arrays(
children,
fields=storage_fields,
mask=values.is_null() if values.null_count else None,
)
if isinstance(target_field.type, pa.ExtensionType):
return pa.ExtensionArray.from_storage(target_field.type, storage)
return storage
def _physical_array_and_type(array: pa.Array) -> tuple[pa.Array, pa.DataType]:
if isinstance(array.type, pa.ExtensionType):
return array.storage, array.type.storage_type
return array, array.type
def _can_coerce_to_blob(data_type: pa.DataType) -> bool:
return _is_binary_like(data_type) or pa.types.is_null(data_type)
def _is_binary_like(data_type: pa.DataType) -> bool:
return (
pa.types.is_binary(data_type)
or pa.types.is_large_binary(data_type)
or pa.types.is_binary_view(data_type)
)
def _field_extension_name(field: pa.Field) -> Optional[str]: def _field_extension_name(field: pa.Field) -> Optional[str]:
extension_name = getattr(field.type, "extension_name", None) extension_name = getattr(field.type, "extension_name", None)
if extension_name is not None: if extension_name is not None:
@@ -463,63 +630,71 @@ def _align_field_types(
target_field = next((f for f in target_fields if f.name == field.name), None) target_field = next((f for f in target_fields if f.name == field.name), None)
if target_field is None: if target_field is None:
raise ValueError(f"Field '{field.name}' not found in target schema") raise ValueError(f"Field '{field.name}' not found in target schema")
# Preserve arrow.json input until it reaches Lance. LanceDB exposes stored new_fields.append(_align_field(field, target_field))
# JSON columns as lance.json (JSONB-backed LargeBinary), but casting the return new_fields
# input to that storage type here merely relabels the raw JSON bytes as
# JSONB. Lance must see arrow.json so it can perform the JSONB encoding.
if ( def _align_list_value_field(
_field_extension_name(field) == "arrow.json" value_field: pa.Field, target_value_field: pa.Field
and _field_extension_name(target_field) == "lance.json" ) -> pa.Field:
): # A list has exactly one child, so the inferred child name ("item") aligns
new_fields.append(field) # positionally and adopts the table's child name; pa.Table.cast renames it.
continue return _align_field(value_field, target_value_field).with_name(
if pa.types.is_struct(target_field.type): target_value_field.name
if pa.types.is_struct(field.type): )
new_type = pa.struct(
_align_field_types(
field.type.fields, def _align_field(field: pa.Field, target_field: pa.Field) -> pa.Field:
target_field.type.fields, # Preserve arrow.json input until it reaches Lance. LanceDB exposes stored
) # JSON columns as lance.json (JSONB-backed LargeBinary), but casting the
# input to that storage type here merely relabels the raw JSON bytes as
# JSONB. Lance must see arrow.json so it can perform the JSONB encoding.
if (
_field_extension_name(field) == "arrow.json"
and _field_extension_name(target_field) == "lance.json"
):
return field
if pa.types.is_struct(target_field.type):
if pa.types.is_struct(field.type):
new_type = pa.struct(
_align_field_types(
field.type.fields,
target_field.type.fields,
) )
else: )
new_type = target_field.type
elif pa.types.is_list(target_field.type):
if _is_list_like(field.type):
new_type = pa.list_(
_align_field_types(
[field.type.value_field],
[target_field.type.value_field],
)[0]
)
else:
new_type = target_field.type
elif pa.types.is_large_list(target_field.type):
if _is_list_like(field.type):
new_type = pa.large_list(
_align_field_types(
[field.type.value_field],
[target_field.type.value_field],
)[0]
)
else:
new_type = target_field.type
elif pa.types.is_fixed_size_list(target_field.type):
if _is_list_like(field.type):
new_type = pa.list_(
_align_field_types(
[field.type.value_field],
[target_field.type.value_field],
)[0],
target_field.type.list_size,
)
else:
new_type = target_field.type
else: else:
new_type = target_field.type new_type = target_field.type
new_fields.append( elif pa.types.is_list(target_field.type):
pa.field(field.name, new_type, field.nullable, target_field.metadata) if _is_list_like(field.type):
) new_type = pa.list_(
return new_fields _align_list_value_field(
field.type.value_field, target_field.type.value_field
)
)
else:
new_type = target_field.type
elif pa.types.is_large_list(target_field.type):
if _is_list_like(field.type):
new_type = pa.large_list(
_align_list_value_field(
field.type.value_field, target_field.type.value_field
)
)
else:
new_type = target_field.type
elif pa.types.is_fixed_size_list(target_field.type):
if _is_list_like(field.type):
new_type = pa.list_(
_align_list_value_field(
field.type.value_field, target_field.type.value_field
),
target_field.type.list_size,
)
else:
new_type = target_field.type
else:
new_type = target_field.type
return pa.field(field.name, new_type, field.nullable, target_field.metadata)
def _infer_subschema( def _infer_subschema(
@@ -588,7 +763,7 @@ def sanitize_create_table(
schema = data.schema schema = data.schema
else: else:
if schema is not None: if schema is not None:
data = pa.Table.from_pylist([], schema) data = pa.Table.from_batches([], schema=schema)
if schema is None: if schema is None:
if data is None: if data is None:
raise ValueError("Either data or schema must be provided") raise ValueError("Either data or schema must be provided")
@@ -1168,6 +1343,7 @@ class Table(ABC):
ngram_max_length: int = 3, ngram_max_length: int = 3,
prefix_only: bool = False, prefix_only: bool = False,
block_size: int = 128, block_size: int = 128,
document_granularity: DocumentGranularity = DocumentGranularity.ROW,
wait_timeout: Optional[timedelta] = None, wait_timeout: Optional[timedelta] = None,
name: Optional[str] = None, name: Optional[str] = None,
): ):
@@ -1246,6 +1422,11 @@ class Table(ABC):
The number of documents per compressed posting block. Must be 128 The number of documents per compressed posting block. Must be 128
or 256. A value of 256 uses the experimental FTS V3 format and or 256. A value of 256 uses the experimental FTS V3 format and
may introduce breaking changes. may introduce breaking changes.
document_granularity: DocumentGranularity, default ROW
``ROW`` treats the selected text in one table row as one document.
``LIST_ELEMENT`` treats each element of the deepest list on the field
path as one document and returns its physical coordinates in
``_doc_index`` for matching queries.
wait_timeout: timedelta, optional wait_timeout: timedelta, optional
The timeout to wait if indexing is asynchronous. The timeout to wait if indexing is asynchronous.
name: str, optional name: str, optional
@@ -1366,7 +1547,9 @@ class Table(ABC):
on: Union[str, Iterable[str]] on: Union[str, Iterable[str]]
A column (or columns) to join on. This is how records from the A column (or columns) to join on. This is how records from the
source table and target table are matched. Typically this is some source table and target table are matched. Typically this is some
kind of key or id column. kind of key or id column. Passing several columns matches on the
composite key: a source row updates a target row only when it
agrees on every one of them.
Examples Examples
-------- --------
@@ -1497,9 +1680,9 @@ class Table(ABC):
Offsets are mostly useful for sampling as the set of all valid offsets is easily Offsets are mostly useful for sampling as the set of all valid offsets is easily
known in advance to be [0, len(table)). known in advance to be [0, len(table)).
No guarantees are made regarding the order in which results are returned. If No guarantees are made regarding the order in which results are returned.
you desire an output order that matches the order of the given offsets, you will Repeated offsets produce repeated rows, which makes this method suitable for
need to add the row offset column to the output and align it yourself. sampling with replacement.
Parameters Parameters
---------- ----------
@@ -1610,6 +1793,9 @@ class Table(ABC):
The result has the same length and order as ``row_ids``. Null blobs The result has the same length and order as ``row_ids``. Null blobs
produce null slots; valid empty blobs produce ``b""``. produce null slots; valid empty blobs produce ``b""``.
``_rowid`` values stay valid after compaction when the table has stable
row ids.
Convenience for small payloads. For large values use Convenience for small payloads. For large values use
:meth:`fetch_blob_files`. :meth:`fetch_blob_files`.
""" """
@@ -1627,6 +1813,9 @@ class Table(ABC):
The result has the same length and order as ``requests``; null blobs The result has the same length and order as ``requests``; null blobs
produce null slots and empty ranges on non-null blobs produce ``b""``. produce null slots and empty ranges on non-null blobs produce ``b""``.
``_rowid`` values stay valid after compaction when the table has stable
row ids.
Row IDs can be obtained from a query with ``with_row_id(True)``. This Row IDs can be obtained from a query with ``with_row_id(True)``. This
API is currently supported only by local tables. API is currently supported only by local tables.
""" """
@@ -1642,6 +1831,9 @@ class Table(ABC):
``_rowid`` or a ``_lance_row_id`` field on the blob descriptor. Null ``_rowid`` or a ``_lance_row_id`` field on the blob descriptor. Null
rows are ``None``. Remote tables require LanceDB Cloud server 0.5.0 or rows are ``None``. Remote tables require LanceDB Cloud server 0.5.0 or
newer. newer.
``_rowid`` values stay valid after compaction when the table has stable
row ids.
""" """
@abstractmethod @abstractmethod
@@ -1737,7 +1929,7 @@ class Table(ABC):
@abstractmethod @abstractmethod
def update( def update(
self, self,
where: Optional[str] = None, where: Optional[Union[str, Expr]] = None,
values: Optional[dict] = None, values: Optional[dict] = None,
*, *,
values_sql: Optional[Dict[str, str]] = None, values_sql: Optional[Dict[str, str]] = None,
@@ -1752,9 +1944,11 @@ class Table(ABC):
Parameters Parameters
---------- ----------
where: str, optional where: str or [Expr][lancedb.expr.Expr], optional
The SQL where clause to use when updating rows. For example, 'x = 2' The filter condition. Can be a SQL string or a type-safe
or 'x IN (1, 2, 3)'. The filter must not be empty, or it will error. [Expr][lancedb.expr.Expr] built with [col][lancedb.expr.col] and
[lit][lancedb.expr.lit]. The filter must not be empty, or it will
error.
values: dict, optional values: dict, optional
The values to update. The keys are the column names and the values The values to update. The keys are the column names and the values
are the values to set. are the values to set.
@@ -1772,6 +1966,7 @@ class Table(ABC):
Examples Examples
-------- --------
>>> import lancedb >>> import lancedb
>>> from lancedb.expr import col
>>> import pandas as pd >>> import pandas as pd
>>> data = pd.DataFrame({"x": [1, 2, 3], "vector": [[1.0, 2], [3, 4], [5, 6]]}) >>> data = pd.DataFrame({"x": [1, 2, 3], "vector": [[1.0, 2], [3, 4], [5, 6]]})
>>> db = lancedb.connect("./.lancedb") >>> db = lancedb.connect("./.lancedb")
@@ -1781,7 +1976,7 @@ class Table(ABC):
0 1 [1.0, 2.0] 0 1 [1.0, 2.0]
1 2 [3.0, 4.0] 1 2 [3.0, 4.0]
2 3 [5.0, 6.0] 2 3 [5.0, 6.0]
>>> table.update(where="x = 2", values={"vector": [10.0, 10]}) >>> table.update(where=col("x") == 2, values={"vector": [10.0, 10]})
UpdateResult(rows_updated=1, version=2) UpdateResult(rows_updated=1, version=2)
>>> table.to_pandas() >>> table.to_pandas()
x vector x vector
@@ -1981,9 +2176,11 @@ class Table(ABC):
Function columns are supported only on LanceDB Cloud and Function columns are supported only on LanceDB Cloud and
Enterprise. Enterprise.
computed: Dict[str, str], optional computed: Dict[str, str], optional
A map of column name to a SQL expression defining the column. The A mapping from output column names to SQL expressions derives each
column's type and inputs are derived from the expression, so no output field from its expression. A direct projection of a Blob v2
data type is supplied. field inherits Blob v2 semantics; other expressions derive their
ordinary Arrow type. Mapping order is declaration and dependency
order.
Unlike ``transforms``, the expression is stored rather than Unlike ``transforms``, the expression is stored rather than
evaluated now: the column is committed with no values, and rows get evaluated now: the column is committed with no values, and rows get
@@ -2120,12 +2317,25 @@ class Table(ABC):
---------- ----------
updates : dict updates : dict
One or more dicts, each with: One or more dicts, each with:
- "path": str dot-path to the field (e.g. "embedding" or "a.b.c"). - "path": str dot-path to the field (e.g. "embedding" or "a.b.c").
- "metadata": dict[str, str | None] keys to set; a value of ``None`` - "metadata": dict[str, str | None] keys to set; a value of ``None``
deletes that key. deletes that key.
- "replace": bool, optional replace the field's whole metadata map - "replace": bool, optional replace the field's whole metadata map
instead of merging (default False). instead of merging (default False).
The following keys are treated specially, by convention, and should
be used when appropriate:
- "lancedb:description": for a human-readable description of a field.
- ``"lancedb:tag:<name>"`` for a user-defined key-value tag, where the
suffix names the tag category; e.g. "lancedb:tag:model": "clip".
- "lancedb:logical-column" for a column grouping; e.g. "feature_v1"
and "feature_v2" might be in the same logical column.
- "lancedb:status" for status options ("production", "candidate",
"deprecated", "archived") to designate the current life cycle
state of this column.
Returns Returns
------- -------
UpdateFieldMetadataResult UpdateFieldMetadataResult
@@ -2675,7 +2885,7 @@ class LanceTable(Table):
arrow_tbl = self.to_arrow() arrow_tbl = self.to_arrow()
if blob_mode == "descriptions": if blob_mode == "descriptions":
arrow_tbl = strip_auto_row_ids( arrow_tbl = strip_auto_row_ids(
arrow_tbl, blob_v2_column_paths(self.schema) arrow_tbl, row_addressable_blob_v2_paths(self.schema)
) )
return arrow_tbl.to_pandas(**kwargs) return arrow_tbl.to_pandas(**kwargs)
@@ -3273,6 +3483,7 @@ class LanceTable(Table):
ngram_max_length: int = 3, ngram_max_length: int = 3,
prefix_only: bool = False, prefix_only: bool = False,
block_size: int = 128, block_size: int = 128,
document_granularity: DocumentGranularity = DocumentGranularity.ROW,
name: Optional[str] = None, name: Optional[str] = None,
): ):
"""Create a full-text search index on a column. """Create a full-text search index on a column.
@@ -3324,7 +3535,11 @@ class LanceTable(Table):
tokenizer_configs = self.infer_tokenizer_configs(tokenizer_name) tokenizer_configs = self.infer_tokenizer_configs(tokenizer_name)
tokenizer_configs["custom_stop_words"] = custom_stop_words tokenizer_configs["custom_stop_words"] = custom_stop_words
config = FTS(block_size=block_size, **tokenizer_configs) config = FTS(
block_size=block_size,
document_granularity=document_granularity,
**tokenizer_configs,
)
try: try:
LOOP.run( LOOP.run(
@@ -3816,7 +4031,7 @@ class LanceTable(Table):
def update( def update(
self, self,
where: Optional[str] = None, where: Optional[Union[str, Expr]] = None,
values: Optional[dict] = None, values: Optional[dict] = None,
*, *,
values_sql: Optional[Dict[str, str]] = None, values_sql: Optional[Dict[str, str]] = None,
@@ -3827,9 +4042,11 @@ class LanceTable(Table):
Parameters Parameters
---------- ----------
where: str, optional where: str or [Expr][lancedb.expr.Expr], optional
The SQL where clause to use when updating rows. For example, 'x = 2' The filter condition. Can be a SQL string or a type-safe
or 'x IN (1, 2, 3)'. The filter must not be empty, or it will error. [Expr][lancedb.expr.Expr] built with [col][lancedb.expr.col] and
[lit][lancedb.expr.lit]. The filter must not be empty, or it will
error.
values: dict, optional values: dict, optional
The values to update. The keys are the column names and the values The values to update. The keys are the column names and the values
are the values to set. are the values to set.
@@ -3847,6 +4064,7 @@ class LanceTable(Table):
Examples Examples
-------- --------
>>> import lancedb >>> import lancedb
>>> from lancedb.expr import col
>>> import pandas as pd >>> import pandas as pd
>>> data = pd.DataFrame({"x": [1, 2, 3], "vector": [[1.0, 2], [3, 4], [5, 6]]}) >>> data = pd.DataFrame({"x": [1, 2, 3], "vector": [[1.0, 2], [3, 4], [5, 6]]})
>>> db = lancedb.connect("./.lancedb") >>> db = lancedb.connect("./.lancedb")
@@ -3856,7 +4074,7 @@ class LanceTable(Table):
0 1 [1.0, 2.0] 0 1 [1.0, 2.0]
1 2 [3.0, 4.0] 1 2 [3.0, 4.0]
2 3 [5.0, 6.0] 2 3 [5.0, 6.0]
>>> table.update(where="x = 2", values={"vector": [10.0, 10]}) >>> table.update(where=col("x") == 2, values={"vector": [10.0, 10]})
UpdateResult(rows_updated=1, version=2) UpdateResult(rows_updated=1, version=2)
>>> table.to_pandas() >>> table.to_pandas()
x vector x vector
@@ -3883,6 +4101,7 @@ class LanceTable(Table):
) )
and not self._route_pushdown_to_rust and not self._route_pushdown_to_rust
and self.current_branch() is None and self.current_branch() is None
and query.take_offsets is None
): ):
from lancedb.namespace import _execute_server_side_query from lancedb.namespace import _execute_server_side_query
@@ -5071,7 +5290,9 @@ class AsyncTable:
if blob_mode == "descriptions" or not schema_has_blob_field(schema): if blob_mode == "descriptions" or not schema_has_blob_field(schema):
arrow_tbl = await self.to_arrow() arrow_tbl = await self.to_arrow()
if blob_mode == "descriptions": if blob_mode == "descriptions":
arrow_tbl = strip_auto_row_ids(arrow_tbl, blob_v2_column_paths(schema)) arrow_tbl = strip_auto_row_ids(
arrow_tbl, row_addressable_blob_v2_paths(schema)
)
return arrow_tbl.to_pandas(**kwargs) return arrow_tbl.to_pandas(**kwargs)
if blob_mode == "lazy" and get_uri_scheme(await self.uri()) == "memory": if blob_mode == "lazy" and get_uri_scheme(await self.uri()) == "memory":
@@ -5491,7 +5712,9 @@ class AsyncTable:
on: Union[str, Iterable[str]] on: Union[str, Iterable[str]]
A column (or columns) to join on. This is how records from the A column (or columns) to join on. This is how records from the
source table and target table are matched. Typically this is some source table and target table are matched. Typically this is some
kind of key or id column. kind of key or id column. Passing several columns matches on the
composite key: a source row updates a target row only when it
agrees on every one of them.
Examples Examples
-------- --------
@@ -5774,7 +5997,23 @@ class AsyncTable:
def _sync_query_to_async( def _sync_query_to_async(
self, query: Query self, query: Query
) -> AsyncHybridQuery | AsyncFTSQuery | AsyncVectorQuery | AsyncQuery: ) -> (
AsyncHybridQuery
| AsyncFTSQuery
| AsyncVectorQuery
| AsyncQuery
| AsyncTakeQuery
):
if query.take_offsets is not None:
take_query = self.take_offsets(query.take_offsets)
if query.columns:
take_query = take_query.select(query.columns)
if query.use_lsm is not None:
take_query = take_query.use_lsm(query.use_lsm)
if query.with_row_id:
take_query = take_query.with_row_id()
return take_query
async_query = self.query() async_query = self.query()
if query.limit is not None: if query.limit is not None:
async_query = async_query.limit(query.limit) async_query = async_query.limit(query.limit)
@@ -5839,6 +6078,7 @@ class AsyncTable:
self._namespace_client, self._pushdown_operations self._namespace_client, self._pushdown_operations
) )
and not self._route_pushdown_to_rust and not self._route_pushdown_to_rust
and query.take_offsets is None
): ):
from lancedb.namespace import _execute_server_side_query from lancedb.namespace import _execute_server_side_query
@@ -5970,7 +6210,7 @@ class AsyncTable:
self, self,
updates: Optional[Dict[str, Any]] = None, updates: Optional[Dict[str, Any]] = None,
*, *,
where: Optional[str] = None, where: Optional[Union[str, Expr]] = None,
updates_sql: Optional[Dict[str, str]] = None, updates_sql: Optional[Dict[str, str]] = None,
) -> UpdateResult: ) -> UpdateResult:
""" """
@@ -5985,9 +6225,11 @@ class AsyncTable:
The updates to apply. The keys should be the name of the column to The updates to apply. The keys should be the name of the column to
update. The values should be the new values to assign. This is update. The values should be the new values to assign. This is
required unless updates_sql is supplied. required unless updates_sql is supplied.
where: str, optional where: str or [Expr][lancedb.expr.Expr], optional
An SQL filter that controls which rows are updated. For example, 'x = 2' The filter condition. Can be a SQL string or a type-safe
or 'x IN (1, 2, 3)'. Only rows that satisfy this filter will be udpated. [Expr][lancedb.expr.Expr] built with [col][lancedb.expr.col] and
[lit][lancedb.expr.lit]. Only rows that satisfy this filter will
be updated.
updates_sql: dict, optional updates_sql: dict, optional
The updates to apply, expressed as SQL expression strings. The keys should The updates to apply, expressed as SQL expression strings. The keys should
be column names. The values should be SQL expressions. These can be SQL be column names. The values should be SQL expressions. These can be SQL
@@ -6005,13 +6247,14 @@ class AsyncTable:
-------- --------
>>> import asyncio >>> import asyncio
>>> import lancedb >>> import lancedb
>>> from lancedb.expr import col
>>> import pandas as pd >>> import pandas as pd
>>> async def demo_update(): >>> async def demo_update():
... data = pd.DataFrame({"x": [1, 2], "vector": [[1, 2], [3, 4]]}) ... data = pd.DataFrame({"x": [1, 2], "vector": [[1, 2], [3, 4]]})
... db = await lancedb.connect_async("./.lancedb") ... db = await lancedb.connect_async("./.lancedb")
... table = await db.create_table("my_table", data) ... table = await db.create_table("my_table", data)
... # x is [1, 2], vector is [[1, 2], [3, 4]] ... # x is [1, 2], vector is [[1, 2], [3, 4]]
... await table.update({"vector": [10, 10]}, where="x = 2") ... await table.update({"vector": [10, 10]}, where=col("x") == 2)
... # x is [1, 2], vector is [[1, 2], [10, 10]] ... # x is [1, 2], vector is [[1, 2], [10, 10]]
... await table.update(updates_sql={"x": "x + 1"}) ... await table.update(updates_sql={"x": "x + 1"})
... # x is [2, 3], vector is [[1, 2], [10, 10]] ... # x is [2, 3], vector is [[1, 2], [10, 10]]
@@ -6025,7 +6268,8 @@ class AsyncTable:
if updates is not None: if updates is not None:
updates_sql = {k: value_to_sql(v) for k, v in updates.items()} updates_sql = {k: value_to_sql(v) for k, v in updates.items()}
return await self._inner.update(updates_sql, where) predicate = where.to_sql() if isinstance(where, Expr) else where
return await self._inner.update(updates_sql, predicate)
async def add_columns( async def add_columns(
self, self,
@@ -6057,8 +6301,11 @@ class AsyncTable:
Function columns are supported only on LanceDB Cloud and Function columns are supported only on LanceDB Cloud and
Enterprise. Enterprise.
computed: Dict[str, str], optional computed: Dict[str, str], optional
A map of column name to a SQL expression defining the column. The A mapping from output column names to SQL expressions derives each
column's type and inputs are derived from the expression. 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.
Unlike ``transforms``, the expression is stored rather than Unlike ``transforms``, the expression is stored rather than
evaluated now: the column is committed with no values, and rows get evaluated now: the column is committed with no values, and rows get
@@ -6329,6 +6576,9 @@ class AsyncTable:
Offsets are mostly useful for sampling as the set of all valid offsets is easily Offsets are mostly useful for sampling as the set of all valid offsets is easily
known in advance to be [0, len(table)). known in advance to be [0, len(table)).
No guarantees are made regarding the order in which results are returned.
Repeated offsets produce repeated rows.
Parameters Parameters
---------- ----------
offsets: list[int] offsets: list[int]
+2 -2
View File
@@ -105,7 +105,7 @@ def test_quickstart(tmp_path):
tbl.create_index(num_sub_vectors=1) tbl.create_index(num_sub_vectors=1)
# --8<-- [end:create_index] # --8<-- [end:create_index]
# --8<-- [start:delete_rows] # --8<-- [start:delete_rows]
tbl.delete('item = "fizz"') tbl.delete("item = 'fizz'")
# --8<-- [end:delete_rows] # --8<-- [end:delete_rows]
# --8<-- [start:drop_table] # --8<-- [start:drop_table]
db.drop_table("my_table") db.drop_table("my_table")
@@ -201,7 +201,7 @@ async def test_quickstart_async(tmp_path):
await tbl.create_index("vector") await tbl.create_index("vector")
# --8<-- [end:create_index_async] # --8<-- [end:create_index_async]
# --8<-- [start:delete_rows_async] # --8<-- [start:delete_rows_async]
await tbl.delete('item = "fizz"') await tbl.delete("item = 'fizz'")
# --8<-- [end:delete_rows_async] # --8<-- [end:delete_rows_async]
# --8<-- [start:drop_table_async] # --8<-- [start:drop_table_async]
await db.drop_table("my_table_async") await db.drop_table("my_table_async")
@@ -266,7 +266,7 @@ def test_table():
tbl.add(pydantic_model_items) tbl.add(pydantic_model_items)
# --8<-- [end:add_table_from_pydantic] # --8<-- [end:add_table_from_pydantic]
# --8<-- [start:delete_row] # --8<-- [start:delete_row]
tbl.delete('item = "fizz"') tbl.delete("item = 'fizz'")
# --8<-- [end:delete_row] # --8<-- [end:delete_row]
# --8<-- [start:delete_specific_row] # --8<-- [start:delete_specific_row]
data = [ data = [
@@ -538,7 +538,7 @@ async def test_table_async():
await async_tbl.add(pydantic_model_items) await async_tbl.add(pydantic_model_items)
# --8<-- [end:add_table_async_from_pydantic] # --8<-- [end:add_table_async_from_pydantic]
# --8<-- [start:delete_row_async] # --8<-- [start:delete_row_async]
await async_tbl.delete('item = "fizz"') await async_tbl.delete("item = 'fizz'")
# --8<-- [end:delete_row_async] # --8<-- [end:delete_row_async]
# --8<-- [start:delete_specific_row_async] # --8<-- [start:delete_specific_row_async]
data = [ data = [
+608 -2
View File
@@ -2,17 +2,41 @@
# SPDX-FileCopyrightText: Copyright The LanceDB Authors # SPDX-FileCopyrightText: Copyright The LanceDB Authors
import io import io
import subprocess
import sys
import textwrap
import lance
import pyarrow as pa import pyarrow as pa
import pyarrow.compute as pc import pyarrow.compute as pc
import pytest import pytest
from lance.blob import BlobType as LanceBlobType
import lancedb import lancedb
from lancedb._blob import read_row_ids_from_hits, stash_auto_row_ids from lancedb._blob import (
blob_v2_projection_sources,
read_row_ids_from_hits,
stash_auto_row_ids,
)
from lancedb.expr import col
from lancedb.index import FTS from lancedb.index import FTS
from lancedb.schema import blob_column_paths, blob_v2_column_paths from lancedb.schema import blob_column_paths, blob_v2_column_paths
_HIDE_LANCE_BLOB = """\
import importlib.abc
import sys
class _MissingLanceBlob(importlib.abc.MetaPathFinder):
def find_spec(self, fullname, path, target=None):
if fullname == "lance.blob" or fullname.startswith("lance.blob."):
raise ModuleNotFoundError(fullname, name="lance.blob")
sys.modules.pop("lance.blob", None)
sys.meta_path.insert(0, _MissingLanceBlob())
"""
def _blob_table(name, rows): def _blob_table(name, rows):
db = lancedb.connect("memory:///") db = lancedb.connect("memory:///")
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")]) schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
@@ -42,10 +66,204 @@ def _row_ids_by_id(table):
return dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist())) return dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
def _assert_missing_blob_row_ids(exc_info):
message = str(exc_info.value)
assert "row ids" in message
assert "rowaddr" not in message
assert "fragment" not in message
def _assert_fetch_apis_reject_missing_row_ids(table, row_ids):
with pytest.raises(ValueError) as exc_info:
table.fetch_blobs("image", row_ids)
_assert_missing_blob_row_ids(exc_info)
with pytest.raises(ValueError) as exc_info:
table.fetch_blob_files("image", row_ids)
_assert_missing_blob_row_ids(exc_info)
with pytest.raises(ValueError) as exc_info:
table.fetch_blob_ranges("image", [(row_id, 0, 1) for row_id in row_ids])
_assert_missing_blob_row_ids(exc_info)
def test_blob_factory_declares_v2_field(): def test_blob_factory_declares_v2_field():
field = lancedb.blob("image") field = lancedb.blob("image")
assert isinstance(field.type, pa.ExtensionType) assert isinstance(field.type, pa.ExtensionType)
assert field.type.extension_name == "lance.blob.v2" assert field.type.extension_name == "lance.blob.v2"
assert lancedb.BlobType is LanceBlobType
assert type(field.type) is LanceBlobType
def test_blob_type_works_without_pylance():
script = _HIDE_LANCE_BLOB + textwrap.dedent(
"""\
import lancedb
import pyarrow as pa
field = lancedb.blob("image")
if not isinstance(field.type, pa.ExtensionType):
raise SystemExit("expected an extension type")
if field.type.extension_name != "lance.blob.v2":
raise SystemExit(field.type.extension_name)
if lancedb.BlobType is not type(field.type):
raise SystemExit("BlobType is not the field type class")
if lancedb.BlobType.__module__ != "lancedb.schema":
raise SystemExit(lancedb.BlobType.__module__)
db = lancedb.connect("memory:///")
table = db.create_table(
"images",
schema=pa.schema([pa.field("id", pa.int64()), field]),
)
table.add([{"id": 1, "image": b"hello"}])
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute([{"id": 1, "image": b"updated"}, {"id": 2, "image": b"inserted"}])
)
if result.num_updated_rows != 1 or result.num_inserted_rows != 1:
raise SystemExit(
f"merge_insert rows updated={result.num_updated_rows} "
f"inserted={result.num_inserted_rows}"
)
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_blob_resolves_pylance_type_without_eager_import():
script = textwrap.dedent(
"""\
import sys
import lancedb
if "lance.blob" in sys.modules:
raise SystemExit("import lancedb imported lance.blob")
field = lancedb.blob("image")
from lance.blob import BlobType
if type(field.type) is not BlobType:
raise SystemExit(f"{type(field.type)} is not {BlobType}")
import lance
image = lance.blob_array([b"x"])
if type(image.type) is not BlobType:
raise SystemExit("blob_array used a different class")
if type(image.type) is not type(field.type):
raise SystemExit("field and array classes differ")
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_blob_fallback_fails_if_name_already_registered():
script = _HIDE_LANCE_BLOB + textwrap.dedent(
"""\
import pyarrow as pa
class OtherBlobType(pa.ExtensionType):
def __init__(self):
super().__init__(
pa.struct([pa.field("data", pa.large_binary())]),
"lance.blob.v2",
)
def __arrow_ext_serialize__(self):
return b""
@classmethod
def __arrow_ext_deserialize__(cls, storage_type, serialized):
return cls()
pa.register_extension_type(OtherBlobType())
import lancedb
try:
lancedb.blob("image")
except ValueError as err:
if "already registered" not in str(err):
raise SystemExit(err)
else:
raise SystemExit("expected ValueError")
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_blob_type_rejects_competing_registration_with_pylance():
script = textwrap.dedent(
"""\
import pyarrow as pa
import pyarrow.ipc
class OtherBlobType(pa.ExtensionType):
def __init__(self):
super().__init__(
pa.struct(
[
pa.field("data", pa.large_binary()),
pa.field("uri", pa.utf8()),
pa.field("position", pa.uint64()),
pa.field("size", pa.uint64()),
]
),
"lance.blob.v2",
)
def __arrow_ext_serialize__(self):
return b""
@classmethod
def __arrow_ext_deserialize__(cls, storage_type, serialized):
return cls()
pa.register_extension_type(OtherBlobType())
from lance.blob import BlobType
if BlobType is OtherBlobType:
raise SystemExit("pylance BlobType was replaced")
schema = pa.schema([pa.field("value", BlobType())])
restored = pa.ipc.read_schema(schema.serialize())
if type(restored.field("value").type) is not OtherBlobType:
raise SystemExit(type(restored.field("value").type))
import lancedb
try:
lancedb.blob("image")
except ValueError as err:
if "__main__.OtherBlobType" not in str(err):
raise SystemExit(err)
else:
raise SystemExit("expected ValueError")
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_blob_v2_column_paths_include_list_children(): def test_blob_v2_column_paths_include_list_children():
@@ -70,6 +288,14 @@ def test_blob_v2_column_paths_include_list_children():
] ]
def test_blob_v2_projection_sources_use_typed_column_name():
schema = pa.schema([lancedb.blob("blob")])
assert blob_v2_projection_sources(schema, {"blob_alias": col("blob")}) == {
"blob_alias": "blob"
}
def _legacy_v1_table(name): def _legacy_v1_table(name):
db = lancedb.connect("memory:///") db = lancedb.connect("memory:///")
schema = pa.schema( schema = pa.schema(
@@ -166,6 +392,20 @@ async def test_async_table_to_pandas_descriptions_mode_omits_row_id():
assert set(descriptor.keys()) == {"kind", "position", "size", "blob_id", "blob_uri"} assert set(descriptor.keys()) == {"kind", "position", "size", "blob_id", "blob_uri"}
@pytest.mark.asyncio
async def test_async_typed_blob_projection_preserves_source_column():
db = await lancedb.connect_async("memory:///typed_blob_projection")
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("blob")])
table = await db.create_table("typed_blob_projection", schema=schema)
await table.add([{"id": 1, "blob": b"alpha"}])
hits = await table.query().select({"blob_alias": col("blob")}).to_arrow()
assert "_lance_row_id" in hits.schema.field("blob_alias").type.names
blobs = await table.fetch_blobs("blob", hits)
assert blobs.to_pylist() == [b"alpha"]
def test_fetch_blobs_round_trip(): def test_fetch_blobs_round_trip():
table = _blob_table( table = _blob_table(
"round_trip", "round_trip",
@@ -176,6 +416,292 @@ def test_fetch_blobs_round_trip():
assert [blobs[0].as_py(), blobs[1].as_py()] == [b"alpha", b"beta"] assert [blobs[0].as_py(), blobs[1].as_py()] == [b"alpha", b"beta"]
def test_merge_insert_writes_python_bytes():
table = _blob_table("merge_bytes", [{"id": 1, "image": b"before"}])
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute([{"id": 1, "image": b"updated"}, {"id": 2, "image": b"inserted"}])
)
assert result.num_updated_rows == 1
assert result.num_inserted_rows == 1
by_id = _row_ids_by_id(table)
blobs = table.fetch_blobs("image", [by_id[1], by_id[2]])
assert blobs.to_pylist() == [b"updated", b"inserted"]
def test_merge_insert_bytes_after_reopen_without_touching_blob_type(tmp_path):
db = lancedb.connect(tmp_path)
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
table = db.create_table("images", schema=schema)
table.add([{"id": 1, "image": b"hello"}])
script = textwrap.dedent(
f"""\
import lancedb
db = lancedb.connect({str(tmp_path)!r})
table = db.open_table("images")
image_type = table.schema.field("image").type
if type(image_type).__name__ != "StructType":
raise SystemExit(f"expected StructType, got {{type(image_type)}}")
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute(
[{{"id": 1, "image": b"updated"}}, {{"id": 2, "image": b"inserted"}}]
)
)
if result.num_updated_rows != 1 or result.num_inserted_rows != 1:
raise SystemExit(
f"rows updated={{result.num_updated_rows}} "
f"inserted={{result.num_inserted_rows}}"
)
hits = table.search().with_row_id(True).limit(10).to_arrow()
by_id = dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
blobs = table.fetch_blobs("image", [by_id[1], by_id[2]])
if blobs.to_pylist() != [b"updated", b"inserted"]:
raise SystemExit(blobs.to_pylist())
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_merge_insert_bytes_after_reopen_without_pylance(tmp_path):
db = lancedb.connect(tmp_path)
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
table = db.create_table("images", schema=schema)
table.add([{"id": 1, "image": b"hello"}])
script = _HIDE_LANCE_BLOB + textwrap.dedent(
f"""\
import lancedb
db = lancedb.connect({str(tmp_path)!r})
table = db.open_table("images")
image_type = table.schema.field("image").type
if type(image_type).__name__ != "StructType":
raise SystemExit(f"expected StructType, got {{type(image_type)}}")
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute(
[{{"id": 1, "image": b"updated"}}, {{"id": 2, "image": b"inserted"}}]
)
)
if result.num_updated_rows != 1 or result.num_inserted_rows != 1:
raise SystemExit(
f"rows updated={{result.num_updated_rows}} "
f"inserted={{result.num_inserted_rows}}"
)
hits = table.search().with_row_id(True).limit(10).to_arrow()
by_id = dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
blobs = table.fetch_blobs("image", [by_id[1], by_id[2]])
if blobs.to_pylist() != [b"updated", b"inserted"]:
raise SystemExit(blobs.to_pylist())
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_merge_insert_blob_array_into_reopened_unregistered_table(tmp_path):
db = lancedb.connect(tmp_path)
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
table = db.create_table("images", schema=schema)
table.add([{"id": 1, "image": b"before"}])
script = textwrap.dedent(
f"""\
import pyarrow as pa
import lancedb
db = lancedb.connect({str(tmp_path)!r})
table = db.open_table("images")
image_type = table.schema.field("image").type
if type(image_type).__name__ != "StructType":
raise SystemExit(
f"expected StructType before lance import, got {{type(image_type)}}"
)
import lance
updates = pa.Table.from_arrays(
[
pa.array([1, 2], type=pa.int64()),
lance.blob_array([b"updated", b"inserted"]),
],
names=["id", "image"],
)
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute(updates)
)
if result.num_updated_rows != 1 or result.num_inserted_rows != 1:
raise SystemExit(
f"rows updated={{result.num_updated_rows}} "
f"inserted={{result.num_inserted_rows}}"
)
hits = table.search().with_row_id(True).limit(10).to_arrow()
by_id = dict(zip(hits["id"].to_pylist(), hits["_rowid"].to_pylist()))
blobs = table.fetch_blobs("image", [by_id[1], by_id[2]])
if blobs.to_pylist() != [b"updated", b"inserted"]:
raise SystemExit(blobs.to_pylist())
"""
)
result = subprocess.run(
[sys.executable, "-c", script],
capture_output=True,
text=True,
check=False,
)
assert result.returncode == 0, result.stderr
def test_add_all_null_blob_column():
db = lancedb.connect("memory:///")
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
table = db.create_table("all_null", schema=schema)
table.add([{"id": 1, "image": None}, {"id": 2, "image": None}])
by_id = _row_ids_by_id(table)
blobs = table.fetch_blobs("image", [by_id[1], by_id[2]])
assert blobs.to_pylist() == [None, None]
def test_create_table_nested_blob_schema_without_rows():
db = lancedb.connect("memory:///")
schema = pa.schema(
[
pa.field("id", pa.int64()),
pa.field("info", pa.struct([lancedb.blob("blob")])),
pa.field("images", pa.list_(lancedb.blob("image"))),
]
)
table = db.create_table("nested_empty", schema=schema)
assert table.count_rows() == 0
def test_merge_insert_nested_blob_dicts():
db = lancedb.connect("memory:///")
info = pa.StructArray.from_arrays(
[
pa.array(["first"], type=pa.string()),
_blob_array("blob", [b"before"]),
],
names=["name", "blob"],
)
data = pa.Table.from_arrays(
[pa.array([1], type=pa.int64()), info],
names=["id", "info"],
)
table = db.create_table("nested_merge", data=data)
result = (
table.merge_insert("id")
.when_matched_update_all()
.execute([{"id": 1, "info": {"name": "first", "blob": b"after"}}])
)
assert result.num_updated_rows == 1
by_id = _row_ids_by_id(table)
blobs = table.fetch_blobs("info.blob", [by_id[1]])
assert blobs.to_pylist() == [b"after"]
def _list_blob_table(name):
db = lancedb.connect("memory:///")
blob_field = lancedb.blob("image")
images = pa.ListArray.from_arrays(
pa.array([0, 1], type=pa.int32()), _blob_array("image", [b"before"])
)
data = pa.Table.from_arrays(
[pa.array([1], type=pa.int64()), images],
schema=pa.schema(
[pa.field("id", pa.int64()), pa.field("images", pa.list_(blob_field))]
),
)
return db.create_table(name, data=data)
def test_merge_insert_list_blob_dicts():
table = _list_blob_table("list_merge")
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute([{"id": 1, "images": [b"one", b"two"]}, {"id": 2, "images": None}])
)
assert result.num_updated_rows == 1
assert result.num_inserted_rows == 1
hits = table.search().limit(10).to_arrow()
sizes = {
row["id"]: None if row["images"] is None else [d["size"] for d in row["images"]]
for row in hits.to_pylist()
}
assert sizes == {1: [3, 3], 2: None}
def test_list_blob_column_queries_as_raw_descriptors():
table = _list_blob_table("list_query")
hits = table.search().limit(10).to_arrow()
element = hits.schema.field("images").type.value_type
assert pa.types.is_struct(element)
assert "_lance_row_id" not in element.names
with pytest.raises(ValueError, match="expected struct before segment"):
table.fetch_blobs("images.image", [0])
def test_row_addressable_paths_exclude_list_children():
from lancedb.schema import row_addressable_blob_v2_paths
schema = pa.schema(
[
pa.field("id", pa.int64()),
pa.field("info", pa.struct([lancedb.blob("blob")])),
pa.field("images", pa.list_(lancedb.blob("image"))),
]
)
assert blob_v2_column_paths(schema) == ["info.blob", "images.image"]
assert row_addressable_blob_v2_paths(schema) == ["info.blob"]
def test_merge_insert_writes_pylance_blob_array():
table = _blob_table("merge_pylance", [{"id": 1, "image": b"before"}])
image = lance.blob_array([b"updated", b"inserted"])
assert type(image.type) is LanceBlobType
assert type(image.type) is type(lancedb.BlobType())
updates = pa.Table.from_arrays(
[pa.array([1, 2], type=pa.int64()), image], names=["id", "image"]
)
result = (
table.merge_insert("id")
.when_matched_update_all()
.when_not_matched_insert_all()
.execute(updates)
)
assert result.num_updated_rows == 1
assert result.num_inserted_rows == 1
by_id = _row_ids_by_id(table)
blobs = table.fetch_blobs("image", [by_id[1], by_id[2]])
assert blobs.to_pylist() == [b"updated", b"inserted"]
def test_fetch_blobs_accepts_query_result(): def test_fetch_blobs_accepts_query_result():
table = _blob_table("from_result", [{"id": 1, "image": b"gamma"}]) table = _blob_table("from_result", [{"id": 1, "image": b"gamma"}])
hits = table.search().limit(10).to_arrow() hits = table.search().limit(10).to_arrow()
@@ -184,6 +710,25 @@ def test_fetch_blobs_accepts_query_result():
assert {blobs[i].as_py() for i in range(len(blobs))} == {b"gamma"} assert {blobs[i].as_py() for i in range(len(blobs))} == {b"gamma"}
def test_fetch_blobs_after_compact_with_stable_row_ids(tmp_path):
db = lancedb.connect(
tmp_path, storage_options={"new_table_enable_stable_row_ids": "true"}
)
schema = pa.schema([pa.field("id", pa.int64()), lancedb.blob("image")])
table = db.create_table("t", schema=schema)
table.add([{"id": 1, "image": b"frag-one"}])
table.add([{"id": 2, "image": b"frag-two"}])
by_id = _row_ids_by_id(table)
ids = [by_id[1], by_id[2]]
table.optimize()
blobs = table.fetch_blobs("image", ids)
assert blobs.to_pylist() == [b"frag-one", b"frag-two"]
ranges = table.fetch_blob_ranges("image", [(ids[0], 5, 3), (ids[1], 5, 3)])
assert ranges.to_pylist() == [b"one", b"two"]
def test_fetch_blobs_preserves_null_and_empty_values(): def test_fetch_blobs_preserves_null_and_empty_values():
table = _blob_table( table = _blob_table(
"nulls", "nulls",
@@ -232,8 +777,25 @@ def test_fetch_blob_ranges_validates_requests():
with pytest.raises(ValueError, match="offset \\+ length overflowed"): with pytest.raises(ValueError, match="offset \\+ length overflowed"):
table.fetch_blob_ranges("image", [(row_id, 2**64 - 1, 1)]) table.fetch_blob_ranges("image", [(row_id, 2**64 - 1, 1)])
with pytest.raises(ValueError, match="row IDs"): with pytest.raises(ValueError) as exc_info:
table.fetch_blob_ranges("image", [(2**64 - 1, 0, 1)]) table.fetch_blob_ranges("image", [(2**64 - 1, 0, 1)])
_assert_missing_blob_row_ids(exc_info)
def test_fetch_blob_apis_reject_missing_fragment_row_addr():
table = _blob_table("missing_frag", [{"id": 1, "image": b"x"}])
live = _row_ids_by_id(table)[1]
_assert_fetch_apis_reject_missing_row_ids(table, [1 << 32, live])
def test_fetch_blob_apis_reject_deleted_row_ids():
table = _blob_table(
"deleted_rows",
[{"id": 1, "image": b"one"}, {"id": 2, "image": b"two"}],
)
by_id = _row_ids_by_id(table)
table.delete("id = 2")
_assert_fetch_apis_reject_missing_row_ids(table, [by_id[2], by_id[1]])
def test_fetch_blob_ranges_empty_requests_returns_empty_array(): def test_fetch_blob_ranges_empty_requests_returns_empty_array():
@@ -403,6 +965,50 @@ async def test_blob_v2_hybrid_fetch_blobs_async():
assert {blobs[i].as_py() for i in range(len(blobs))} == {b"alpha", b"beta"} assert {blobs[i].as_py() for i in range(len(blobs))} == {b"alpha", b"beta"}
@pytest.mark.asyncio
async def test_async_hybrid_typed_blob_projection_preserves_source_column():
db = await lancedb.connect_async("memory:///hybrid_typed_blob")
schema = pa.schema(
[
pa.field("id", pa.int64()),
pa.field("text", pa.utf8()),
pa.field("vector", pa.list_(pa.float32(), list_size=2)),
lancedb.blob("blob"),
]
)
table = await db.create_table("hybrid_typed_blob", schema=schema)
await table.add(
[
{
"id": 1,
"text": "hello alpha",
"vector": [1.0, 0.0],
"blob": b"alpha",
},
{
"id": 2,
"text": "hello beta",
"vector": [0.9, 0.1],
"blob": b"beta",
},
]
)
await table.create_index("text", config=FTS(with_position=False))
hits = await (
table.query()
.nearest_to([1.0, 0.0])
.nearest_to_text("hello")
.select({"blob_alias": col("blob")})
.limit(2)
.to_arrow()
)
assert "_lance_row_id" in hits.schema.field("blob_alias").type.names
blobs = await table.fetch_blobs("blob", hits)
assert {blobs[i].as_py() for i in range(len(blobs))} == {b"alpha", b"beta"}
def test_blob_file_seek_read_and_read_range(): def test_blob_file_seek_read_and_read_range():
payload = _identifiable_payload(1024) payload = _identifiable_payload(1024)
table = _blob_table("seek_read", [{"id": 1, "image": payload}]) table = _blob_table("seek_read", [{"id": 1, "image": payload}])
+21 -21
View File
@@ -52,7 +52,7 @@ class TestExprConstruction:
def test_func(self): def test_func(self):
e = func("lower", col("name")) e = func("lower", col("name"))
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "lower(name)" assert e.to_sql() == "lower(`name`)"
def test_func_unknown_raises(self): def test_func_unknown_raises(self):
with pytest.raises(Exception): with pytest.raises(Exception):
@@ -115,7 +115,7 @@ class TestExprOperators:
def test_and_operator(self): def test_and_operator(self):
e = (col("age") > lit(18)) & (col("status") == lit("active")) e = (col("age") > lit(18)) & (col("status") == lit("active"))
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "((age > 18) AND (status = 'active'))" assert e.to_sql() == "((age > 18) AND (`status` = 'active'))"
def test_or_operator(self): def test_or_operator(self):
e = (col("a") == lit(1)) | (col("b") == lit(2)) e = (col("a") == lit(1)) | (col("b") == lit(2))
@@ -166,7 +166,7 @@ class TestExprOperators:
def test_coerce_plain_str(self): def test_coerce_plain_str(self):
e = col("name") == "alice" e = col("name") == "alice"
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "(name = 'alice')" assert e.to_sql() == "(`name` = 'alice')"
def test_reflexive_comparisons(self): def test_reflexive_comparisons(self):
# 10 < col("age") swaps to col("age") > 10 # 10 < col("age") swaps to col("age") > 10
@@ -198,85 +198,85 @@ class TestExprBytesLiteral:
def test_bytes_equality_expr_sql(self): def test_bytes_equality_expr_sql(self):
e = col("data") == lit(b"\xca\xfe") e = col("data") == lit(b"\xca\xfe")
assert e.to_sql() == "(data = X'CAFE')" assert e.to_sql() == "(`data` = X'CAFE')"
def test_bytes_ne_expr_sql(self): def test_bytes_ne_expr_sql(self):
e = col("data") != lit(b"\xff") e = col("data") != lit(b"\xff")
assert e.to_sql() == "(data <> X'FF')" assert e.to_sql() == "(`data` <> X'FF')"
def test_bytes_compound_expr_sql(self): def test_bytes_compound_expr_sql(self):
e = (col("data") == lit(b"\x01")) & (col("id") > lit(5)) e = (col("data") == lit(b"\x01")) & (col("id") > lit(5))
assert e.to_sql() == "((data = X'01') AND (id > 5))" assert e.to_sql() == "((`data` = X'01') AND (id > 5))"
def test_bytes_in_function_call(self): def test_bytes_in_function_call(self):
# Regression test: binary literals inside scalar function calls # Regression test: binary literals inside scalar function calls
# used to fail because DataFusion's unparser does not support Binary # used to fail because DataFusion's unparser does not support Binary
# scalars. Now handled via a placeholder-substitution rewrite. # scalars. Now handled via a placeholder-substitution rewrite.
e = func("contains", col("data"), lit(b"\xff")) e = func("contains", col("data"), lit(b"\xff"))
assert e.to_sql() == "contains(data, X'FF')" assert e.to_sql() == "contains(`data`, X'FF')"
def test_bytes_in_not(self): def test_bytes_in_not(self):
e = ~(col("data") == lit(b"\xff")) e = ~(col("data") == lit(b"\xff"))
assert e.to_sql() == "NOT (data = X'FF')" assert e.to_sql() == "NOT (`data` = X'FF')"
class TestExprStringMethods: class TestExprStringMethods:
def test_lower(self): def test_lower(self):
e = col("name").lower() e = col("name").lower()
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "lower(name)" assert e.to_sql() == "lower(`name`)"
def test_upper(self): def test_upper(self):
e = col("name").upper() e = col("name").upper()
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "upper(name)" assert e.to_sql() == "upper(`name`)"
def test_contains(self): def test_contains(self):
e = col("text").contains(lit("hello")) e = col("text").contains(lit("hello"))
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "contains(text, 'hello')" assert e.to_sql() == "contains(`text`, 'hello')"
def test_contains_with_str_coerce(self): def test_contains_with_str_coerce(self):
e = col("text").contains("hello") e = col("text").contains("hello")
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "contains(text, 'hello')" assert e.to_sql() == "contains(`text`, 'hello')"
def test_chained_lower_eq(self): def test_chained_lower_eq(self):
e = col("name").lower() == lit("alice") e = col("name").lower() == lit("alice")
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "(lower(name) = 'alice')" assert e.to_sql() == "(lower(`name`) = 'alice')"
class TestExprCast: class TestExprCast:
def test_cast_string(self): def test_cast_string(self):
e = col("id").cast("string") e = col("id").cast("string")
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "CAST(id AS VARCHAR)" assert e.to_sql() == "arrow_cast(id, 'Utf8')"
def test_cast_int32(self): def test_cast_int32(self):
e = col("score").cast("int32") e = col("score").cast("int32")
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "CAST(score AS INTEGER)" assert e.to_sql() == "arrow_cast(score, 'Int32')"
def test_cast_float64(self): def test_cast_float64(self):
e = col("val").cast("float64") e = col("val").cast("float64")
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "CAST(val AS DOUBLE)" assert e.to_sql() == "arrow_cast(val, 'Float64')"
def test_cast_pyarrow_type(self): def test_cast_pyarrow_type(self):
e = col("score").cast(pa.int32()) e = col("score").cast(pa.int32())
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "CAST(score AS INTEGER)" assert e.to_sql() == "arrow_cast(score, 'Int32')"
def test_cast_pyarrow_float64(self): def test_cast_pyarrow_float64(self):
e = col("val").cast(pa.float64()) e = col("val").cast(pa.float64())
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "CAST(val AS DOUBLE)" assert e.to_sql() == "arrow_cast(val, 'Float64')"
def test_cast_pyarrow_string(self): def test_cast_pyarrow_string(self):
e = col("id").cast(pa.string()) e = col("id").cast(pa.string())
assert isinstance(e, Expr) assert isinstance(e, Expr)
assert e.to_sql() == "CAST(id AS VARCHAR)" assert e.to_sql() == "arrow_cast(id, 'Utf8')"
def test_cast_pyarrow_and_string_equivalent(self): def test_cast_pyarrow_and_string_equivalent(self):
# pa.int32() and "int32" should produce equivalent SQL # pa.int32() and "int32" should produce equivalent SQL
@@ -597,14 +597,14 @@ class TestExprIsin:
def test_isin_strs(self): def test_isin_strs(self):
assert ( assert (
col("status").isin(["active", "pending"]).to_sql() col("status").isin(["active", "pending"]).to_sql()
== "status IN ('active', 'pending')" == "`status` IN ('active', 'pending')"
) )
def test_isin_coerces_and_mixes(self): def test_isin_coerces_and_mixes(self):
assert col("id").isin([lit(1), 2]).to_sql() == "id IN (1, 2)" assert col("id").isin([lit(1), 2]).to_sql() == "id IN (1, 2)"
def test_isin_empty(self): def test_isin_empty(self):
assert col("id").isin([]).to_sql() == "id IN ()" assert col("id").isin([]).to_sql() == "false"
def test_isin_filter(self, simple_table): def test_isin_filter(self, simple_table):
result = simple_table.search().where(col("id").isin([1, 3, 5])).to_arrow() result = simple_table.search().where(col("id").isin([1, 3, 5])).to_arrow()
@@ -37,6 +37,22 @@ def job_result(name: str) -> dict:
return json.loads(fixture(name))["result"] return json.loads(fixture(name))["result"]
def assert_no_secret_values(value):
"""No client value models a resolved credential, at any nesting depth."""
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(): def test_public_function_values_are_in_api_reference():
docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md" docs = Path(__file__).parents[3] / "docs" / "src" / "python" / "python.md"
rendered = docs.read_text() rendered = docs.read_text()
@@ -94,6 +110,7 @@ def test_function_version_identity_is_immutable_and_exact():
version = FunctionVersion.from_json(json.dumps(value)) version = FunctionVersion.from_json(json.dumps(value))
assert version.name == "embed" assert version.name == "embed"
assert version.version == "fv_01K3EXACT" assert version.version == "fv_01K3EXACT"
assert dict(version.secret_env_bindings) == {"HF_TOKEN": "hf-prod"}
with pytest.raises((TypeError, ValueError)): with pytest.raises((TypeError, ValueError)):
version.version = "fv_changed" version.version = "fv_changed"
@@ -276,6 +293,25 @@ def test_refresh_result_rejects_non_u64_values(field):
RefreshColumnResult.from_json(json.dumps(value)) RefreshColumnResult.from_json(json.dumps(value))
def test_canonical_client_values_carry_bindings_and_no_credentials():
"""A binding names a Secret; the credential behind it has no client field."""
version = FunctionVersion.from_json(
json.dumps(job_result("remote_function_job.json"))
)
canonical = json.loads(version.to_canonical_json())
assert canonical["secret_env_bindings"] == {"HF_TOKEN": "hf-prod"}
assert_no_secret_values(canonical)
def test_a_version_without_bindings_keeps_the_original_wire_shape():
"""Every Function registered before Secrets existed serializes unchanged."""
value = job_result("remote_function_job.json")
del value["secret_env_bindings"]
version = FunctionVersion.from_json(json.dumps(value))
assert dict(version.secret_env_bindings) == {}
assert "secret_env_bindings" not in json.loads(version.to_canonical_json())
class _FunctionDeclarationInner: class _FunctionDeclarationInner:
def __init__(self): def __init__(self):
self.calls = [] self.calls = []
File diff suppressed because it is too large Load Diff
+77
View File
@@ -25,6 +25,7 @@ from lancedb.db import DBConnection
from lancedb.index import FTS from lancedb.index import FTS
from lancedb.query import ( from lancedb.query import (
BoostQuery, BoostQuery,
DocumentGranularity,
MatchQuery, MatchQuery,
MultiMatchQuery, MultiMatchQuery,
PhraseQuery, PhraseQuery,
@@ -245,6 +246,55 @@ def test_create_inverted_index_rejects_invalid_block_size(table):
table.create_index("text", config=FTS(block_size=129)) table.create_index("text", config=FTS(block_size=129))
def test_list_element_document_granularity(tmp_path):
docs_type = pa.list_(pa.struct([pa.field("content", pa.string())]))
docs = pa.array(
[
[
{"content": "alpha beta"},
None,
{"content": ""},
{"content": "the and"},
{"content": "alpha beta"},
]
],
type=docs_type,
)
table = ldb.connect(tmp_path).create_table(
"list_element_docs", pa.table({"id": [0], "docs": docs})
)
row_table = ldb.connect(tmp_path).create_table(
"row_docs", pa.table({"id": [0], "docs": docs})
)
row_table.create_index("docs.content", config=FTS())
row_result = row_table.search(MatchQuery("alpha", "docs.content")).to_arrow()
assert row_result.num_rows == 1
assert "_doc_index" not in row_result.column_names
granularity = DocumentGranularity.LIST_ELEMENT
table.create_index(
"docs.content",
config=FTS(with_position=True, document_granularity=granularity),
)
assert table.list_indices()[0].columns == ["docs.content"]
def coordinates(query):
result = table.search(query).limit(10).to_arrow()
doc_index_type = result.schema.field("_doc_index").type
assert pa.types.is_list(doc_index_type)
assert doc_index_type.value_type == pa.uint32()
return sorted(result["_doc_index"].to_pylist())
assert coordinates(
MatchQuery("alpha", "docs.content", document_granularity=granularity)
) == [[0], [4]]
assert coordinates(
PhraseQuery("alpha beta", "docs.content", document_granularity=granularity)
) == [[0], [4]]
assert coordinates(MatchQuery("alpha", "docs.content")) == [[0], [4]]
assert FTS().document_granularity is DocumentGranularity.ROW
def test_create_inverted_index_respects_build_memory_limit(table): def test_create_inverted_index_respects_build_memory_limit(table):
with pytest.raises(ValueError, match="exceeds worker memory limit"): with pytest.raises(ValueError, match="exceeds worker memory limit"):
table.create_index( table.create_index(
@@ -1089,6 +1139,20 @@ def test_fts_query_to_json():
) )
assert json_str == expected assert json_str == expected
# Test MatchQuery with list-element document granularity
match_query = MatchQuery(
"hello world",
"text",
document_granularity=DocumentGranularity.LIST_ELEMENT,
)
json_str = match_query.to_json()
expected = (
'{"match":{"column":"text","terms":"hello world","boost":1.0,'
'"fuzziness":0,"max_expansions":50,"operator":"Or","prefix_length":0,'
'"document_granularity":"list_element"}}'
)
assert json_str == expected
# Test MatchQuery with options # Test MatchQuery with options
match_query = MatchQuery("puppy", "text", fuzziness=2, boost=1.5, prefix_length=3) match_query = MatchQuery("puppy", "text", fuzziness=2, boost=1.5, prefix_length=3)
json_str = match_query.to_json() json_str = match_query.to_json()
@@ -1098,6 +1162,19 @@ def test_fts_query_to_json():
) )
assert json_str == expected assert json_str == expected
# Test PhraseQuery with list-element document granularity
phrase_query = PhraseQuery(
"quick brown fox",
"title",
document_granularity=DocumentGranularity.LIST_ELEMENT,
)
json_str = phrase_query.to_json()
expected = (
'{"phrase":{"column":"title","terms":"quick brown fox","slop":0,'
'"document_granularity":"list_element"}}'
)
assert json_str == expected
# Test PhraseQuery # Test PhraseQuery
phrase_query = PhraseQuery("quick brown fox", "title") phrase_query = PhraseQuery("quick brown fox", "title")
json_str = phrase_query.to_json() json_str = phrase_query.to_json()
+24 -6
View File
@@ -54,7 +54,10 @@ class TestOAuthProvider:
provider = OAuthProvider(fetcher) provider = OAuthProvider(fetcher)
headers = provider.get_headers() headers = provider.get_headers()
assert headers == {"Authorization": "Bearer token123"} assert headers == {
"Authorization": "Bearer token123",
"x-lancedb-credential-type": "oidc",
}
assert provider._current_token == "token123" assert provider._current_token == "token123"
assert provider._token_expires_at is not None assert provider._token_expires_at is not None
@@ -73,14 +76,20 @@ class TestOAuthProvider:
# First call # First call
headers1 = provider.get_headers() headers1 = provider.get_headers()
assert headers1 == {"Authorization": "Bearer token1"} assert headers1 == {
"Authorization": "Bearer token1",
"x-lancedb-credential-type": "oidc",
}
# Wait for token to expire # Wait for token to expire
time.sleep(1.1) time.sleep(1.1)
# Second call should refresh # Second call should refresh
headers2 = provider.get_headers() headers2 = provider.get_headers()
assert headers2 == {"Authorization": "Bearer token2"} assert headers2 == {
"Authorization": "Bearer token2",
"x-lancedb-credential-type": "oidc",
}
assert call_count == 2 assert call_count == 2
def test_no_expiry_info(self): def test_no_expiry_info(self):
@@ -92,12 +101,18 @@ class TestOAuthProvider:
provider = OAuthProvider(fetcher) provider = OAuthProvider(fetcher)
headers = provider.get_headers() headers = provider.get_headers()
assert headers == {"Authorization": "Bearer permanent_token"} assert headers == {
"Authorization": "Bearer permanent_token",
"x-lancedb-credential-type": "oidc",
}
assert provider._token_expires_at is None assert provider._token_expires_at is None
# Should not refresh on second call # Should not refresh on second call
headers2 = provider.get_headers() headers2 = provider.get_headers()
assert headers2 == {"Authorization": "Bearer permanent_token"} assert headers2 == {
"Authorization": "Bearer permanent_token",
"x-lancedb-credential-type": "oidc",
}
def test_missing_access_token(self): def test_missing_access_token(self):
"""Test error handling when access_token is missing.""" """Test error handling when access_token is missing."""
@@ -121,7 +136,10 @@ class TestOAuthProvider:
provider = OAuthProvider(fetcher) provider = OAuthProvider(fetcher)
headers = provider.get_headers() headers = provider.get_headers()
assert headers == {"Authorization": "Bearer sync_token"} assert headers == {
"Authorization": "Bearer sync_token",
"x-lancedb-credential-type": "oidc",
}
class TestClientConfigIntegration: class TestClientConfigIntegration:
@@ -266,3 +266,38 @@ async def test_async_namespace_connection_materialized_views(tmp_path):
handle._route_pushdown_to_rust == through_namespace._route_pushdown_to_rust handle._route_pushdown_to_rust == through_namespace._route_pushdown_to_rust
) )
assert handle._namespace_path == through_namespace._namespace_path assert handle._namespace_path == through_namespace._namespace_path
def test_namespaced_select_kind_is_read_and_unknown_kinds_are_refused():
import json
import pyarrow as pa
from lancedb.materialized_view import _definition_from_schema
def schema_with(definition: dict) -> pa.Schema:
return pa.schema([pa.field("id", pa.int32())]).with_metadata(
{b"mv.definition": json.dumps(definition).encode()}
)
# "namespaced_select" is the namespaced form of "select": same shape,
# a separate kind so readers that predate it refuse instead of
# resolving the source at the root.
definition = _definition_from_schema(
schema_with(
{
"kind": "namespaced_select",
"source_table": "people",
"source_namespace": ["ns"],
"projections": [{"output": "name", "expression": "name"}],
}
),
"v",
)
assert definition.source_table == "people"
assert definition.source_namespace == ["ns"]
with pytest.raises(NotImplementedError, match="cannot refresh"):
_definition_from_schema(
schema_with({"kind": "select_v3", "source_table": "people"}), "v"
)
+47
View File
@@ -675,6 +675,21 @@ def test_distance_range(table: lancedb.table.Table):
assert res["_distance"].to_pylist() == [min_dist, max_dist] assert res["_distance"].to_pylist() == [min_dist, max_dist]
@pytest.mark.parametrize("expression", ["1 - _distance", "1.0 - _distance"])
def test_select_arithmetic_with_distance(table, expression):
result = (
table.search([10, 10])
.select({"similarity": expression, "_distance": "_distance"})
.distance_type("cosine")
.to_arrow()
)
assert result.schema.field("similarity").type == pa.float32()
assert result["similarity"].to_pylist() == pytest.approx(
[1 - distance for distance in result["_distance"].to_pylist()]
)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_distance_range_async(table_async: AsyncTable): async def test_distance_range_async(table_async: AsyncTable):
q = [0, 0] q = [0, 0]
@@ -897,6 +912,23 @@ def test_query_builder_batches(table):
assert rs_list["id"][1] == 2 assert rs_list["id"][1] == 2
def test_batch_vector_query_shares_filtered_flat_scan(table):
query = (
table.search([[1.0, 2.0], [3.0, 4.0]])
.where("id > 0", prefilter=True)
.limit(1)
.select(["id"])
)
plan = query.explain_plan(verbose=True)
assert "KNNVectorDistance: queries=2" in plan
assert "UnionExec" not in plan
results = query.to_arrow()
assert len(results) == 2
assert results["query_index"].to_pylist() == [0, 1]
def test_dynamic_projection(table): def test_dynamic_projection(table):
rs = ( rs = (
LanceVectorQueryBuilder(table, [0, 0], "vector") LanceVectorQueryBuilder(table, [0, 0], "vector")
@@ -1891,6 +1923,21 @@ def test_take_queries(tmp_path):
17, 17,
] ]
# Duplicate offsets are occurrences, not set members. Ordering is unspecified.
assert sorted(table.take_offsets([5, 2, 5, 17]).to_pandas()["idx"].to_list()) == [
2,
5,
5,
17,
]
# Converting a take builder to its serializable query representation must
# retain occurrence metadata and execute with the same multiplicity.
query = table.take_offsets([5, 2, 5, 17]).select(["idx"]).to_query_object()
assert query.take_offsets == [5, 2, 5, 17]
converted = table._execute_query(query).read_all()
assert sorted(converted["idx"].to_pylist()) == [2, 5, 5, 17]
# Take by row id # Take by row id
assert list( assert list(
sorted(table.take_row_ids([5, 2, 17]).to_pandas()["idx"].to_list()) sorted(table.take_row_ids([5, 2, 17]).to_pandas()["idx"].to_list())
+199 -21
View File
@@ -479,24 +479,49 @@ def test_remote_permutation_is_picklable():
match = re.search( match = re.search(
r"_rowoffset\s+in\s+\((.*?)\)", body["filter"], re.IGNORECASE r"_rowoffset\s+in\s+\((.*?)\)", body["filter"], re.IGNORECASE
) )
offsets = [int(o.strip()) for o in match.group(1).split(",")] offsets = list(
dict.fromkeys(int(o.strip()) for o in match.group(1).split(","))
)
else: else:
offsets = list(range(len(rows))) offsets = list(range(len(rows)))
table = pa.table({"a": [rows[offset] for offset in offsets]}) columns = body.get("columns") or ["a"]
table = pa.table(
{
column: (
[rows[offset] for offset in offsets]
if column == "a"
else offsets
)
for column in columns
}
)
request.send_response(200) request.send_response(200)
request.send_header("Content-Type", "application/vnd.apache.arrow.file") request.send_header("Content-Type", "application/vnd.apache.arrow.file")
request.end_headers() request.end_headers()
with pa.ipc.new_file(request.wfile, schema=table.schema) as writer: with pa.ipc.new_file(request.wfile, schema=table.schema) as writer:
writer.write_table(table) writer.write_table(table, max_chunksize=2)
else: else:
request.send_response(404) request.send_response(404)
request.end_headers() request.end_headers()
with mock_lancedb_connection(handler) as db: with mock_lancedb_connection(handler) as db:
permutation = Permutation.identity(db.open_table("test")) table = db.open_table("test")
assert table.take_offsets([0, 2, 0, 4]).to_list() == [
{"a": 0},
{"a": 0},
{"a": 2},
{"a": 4},
]
permutation = Permutation.identity(table)
restored = pickle.loads(pickle.dumps(permutation)) restored = pickle.loads(pickle.dumps(permutation))
assert restored.__getitems__([0, 2, 4]) == [{"a": 0}, {"a": 2}, {"a": 4}] assert restored.__getitems__([0, 2, 0, 4]) == [
{"a": 0},
{"a": 2},
{"a": 0},
{"a": 4},
]
def test_create_table_exist_ok(): def test_create_table_exist_ok():
@@ -795,11 +820,13 @@ def test_table_create_indices():
scalar_req = received_requests[0] scalar_req = received_requests[0]
assert "name" in scalar_req assert "name" in scalar_req
assert scalar_req["name"] == "custom_scalar_idx" assert scalar_req["name"] == "custom_scalar_idx"
assert scalar_req["replace"] is False
# Check FTS index request has custom name # Check FTS index request has custom name
fts_req = received_requests[1] fts_req = received_requests[1]
assert "name" in fts_req assert "name" in fts_req
assert fts_req["name"] == "custom_fts_idx" assert fts_req["name"] == "custom_fts_idx"
assert fts_req["replace"] is False
assert fts_req["block_size"] == 256 assert fts_req["block_size"] == 256
assert fts_req["custom_stop_words"] == ["cloud"] assert fts_req["custom_stop_words"] == ["cloud"]
@@ -807,6 +834,7 @@ def test_table_create_indices():
vector_req = received_requests[2] vector_req = received_requests[2]
assert "name" in vector_req assert "name" in vector_req
assert vector_req["name"] == "custom_vector_idx" assert vector_req["name"] == "custom_vector_idx"
assert "replace" not in vector_req
table.wait_for_index(["custom_scalar_idx"], timedelta(seconds=2)) table.wait_for_index(["custom_scalar_idx"], timedelta(seconds=2))
table.wait_for_index( table.wait_for_index(
@@ -1079,6 +1107,9 @@ def test_remote_create_index_new_api():
table.create_index("text", config=FTS(block_size=256)) table.create_index("text", config=FTS(block_size=256))
# IvfRq via new API # IvfRq via new API
table.create_index("vector", config=IvfRq(distance_type="l2")) table.create_index("vector", config=IvfRq(distance_type="l2"))
table.create_index(
"vector", config=IvfPq(distance_type="l2"), replace=False
)
# Legacy index_type="IVF_RQ" routes to IvfRq config under the hood. # Legacy index_type="IVF_RQ" routes to IvfRq config under the hood.
with pytest.warns(DeprecationWarning, match="create_index"): with pytest.warns(DeprecationWarning, match="create_index"):
@@ -1088,15 +1119,17 @@ def test_remote_create_index_new_api():
num_partitions=8, num_partitions=8,
) )
assert len(received_requests) == 5 assert len(received_requests) == 6
assert [req["column"] for req in received_requests] == [ assert [req["column"] for req in received_requests] == [
"vector", "vector",
"category", "category",
"text", "text",
"vector", "vector",
"vector", "vector",
"vector",
] ]
assert received_requests[2]["block_size"] == 256 assert received_requests[2]["block_size"] == 256
assert received_requests[4]["replace"] is False
def test_table_wait_for_index_timeout(): def test_table_wait_for_index_timeout():
@@ -1618,6 +1651,49 @@ def test_query_sync_fts():
) )
def test_query_sync_fts_document_granularity():
from lancedb.query import DocumentGranularity, MatchQuery
def handler(body):
assert body == {
"full_text_query": {
"query": {
"match": {
"column": "docs.content",
"terms": "alpha",
"boost": 1.0,
"fuzziness": 0,
"max_expansions": 50,
"operator": "Or",
"prefix_length": 0,
"document_granularity": "list_element",
}
}
},
"k": 10,
"prefilter": True,
"vector": [],
"version": None,
}
return pa.table(
{
"id": [1, 1],
"_doc_index": pa.array([[0], [4]], type=pa.list_(pa.uint32())),
}
)
with query_test_table(handler, server_version=Version("0.6.0")) as table:
result = table.search(
MatchQuery(
"alpha",
"docs.content",
document_granularity=DocumentGranularity.LIST_ELEMENT,
)
).to_arrow()
assert result["_doc_index"].to_pylist() == [[0], [4]]
def test_query_sync_hybrid(): def test_query_sync_hybrid():
def handler(body): def handler(body):
if "full_text_query" in body: if "full_text_query" in body:
@@ -2391,7 +2467,7 @@ def test_remote_blob_byte_apis_not_supported_on_old_server():
def test_remote_connection_jobs_surface(): def test_remote_connection_jobs_surface():
from lancedb.exceptions import JobFailedError from lancedb.exceptions import JobFailedError, JobNotFoundError
schema = pa.schema([("state", pa.string())]) schema = pa.schema([("state", pa.string())])
batch = pa.record_batch([pa.array(["created", "done"])], schema=schema) batch = pa.record_batch([pa.array(["created", "done"])], schema=schema)
@@ -2399,6 +2475,7 @@ def test_remote_connection_jobs_surface():
with pa.ipc.new_stream(sink, schema) as writer: with pa.ipc.new_stream(sink, schema) as writer:
writer.write_batch(batch) writer.write_batch(batch)
events_body = sink.getvalue().to_pybytes() events_body = sink.getvalue().to_pybytes()
query_events_payloads = []
def handler(request): def handler(request):
content_len = int(request.headers.get("Content-Length", 0)) content_len = int(request.headers.get("Content-Length", 0))
@@ -2436,6 +2513,22 @@ def test_remote_connection_jobs_surface():
request.end_headers() request.end_headers()
request.wfile.write(json.dumps(rsp).encode()) request.wfile.write(json.dumps(rsp).encode())
elif request.path == "/v1/jobs/describe": elif request.path == "/v1/jobs/describe":
if payload["job_id"] == "job-2":
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
dict(
job_id="job-2",
job_type="refresh_column",
job_state="DONE",
creation_ms=2000,
result=dict(rows_assigned=1000000, rows_failed=0),
)
).encode()
)
return
if payload["job_id"] != "job-1": if payload["job_id"] != "job-1":
request.send_response(404) request.send_response(404)
request.end_headers() request.end_headers()
@@ -2467,7 +2560,7 @@ def test_remote_connection_jobs_surface():
request.end_headers() request.end_headers()
request.wfile.write(b'{"job_id": "job-1"}') request.wfile.write(b'{"job_id": "job-1"}')
elif request.path == "/v1/jobs/query_events": elif request.path == "/v1/jobs/query_events":
assert payload["job_id"] == "job-1" query_events_payloads.append(payload)
request.send_response(200) request.send_response(200)
request.send_header("Content-Type", "application/vnd.apache.arrow.stream") request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
request.end_headers() request.end_headers()
@@ -2483,24 +2576,109 @@ def test_remote_connection_jobs_surface():
assert jobs[0].table == "t1" assert jobs[0].table == "t1"
assert jobs[1].state == "finished" assert jobs[1].state == "finished"
description = db.get_job("job-1")
assert description.job_type == "create_index"
assert description.state == "failed"
assert json.loads(description.spec_json) == {"column": "vec"}
assert description.failure.message == "worker died"
assert description.failure.retryable is True
assert db.get_job("missing") is None
assert db.cancel_job("job-1") is True assert db.cancel_job("job-1") is True
assert db.cancel_job("missing") is False assert db.cancel_job("missing") is False
batches = db.job_history("job-1") # Opening a job hands back a populated handle; a missing one fails.
assert len(batches) == 1 with pytest.raises(JobNotFoundError, match="missing"):
assert batches[0].num_rows == 2 db.open_job("missing")
assert batches[0].column("state").to_pylist() == ["created", "done"] finished = db.open_job("job-2")
assert finished.state == "finished"
assert finished.result == {"rows_assigned": 1000000, "rows_failed": 0}
job = db.job("job-1") job = db.open_job("job-1")
assert job.id == "job-1" assert job.id == "job-1"
# Opening already populated the handle.
assert job.state == "failed"
assert job.spec == {"column": "vec"}
assert job.failure.message == "worker died"
assert job.status() == "failed" assert job.status() == "failed"
with pytest.raises(JobFailedError, match="worker died"): with pytest.raises(JobFailedError, match="worker died"):
job.wait(timeout=timedelta(seconds=5)) job.wait(timeout=timedelta(seconds=5))
def test_remote_job_handle_reports_its_own_detail():
schema = pa.schema([("state", pa.string())])
batch = pa.record_batch([pa.array(["claim_complete"])], schema=schema)
sink = pa.BufferOutputStream()
with pa.ipc.new_stream(sink, schema) as writer:
writer.write_batch(batch)
events_body = sink.getvalue().to_pybytes()
event_payloads = []
def handler(request):
content_len = int(request.headers.get("Content-Length", 0))
body = request.rfile.read(content_len) if content_len > 0 else b""
payload = json.loads(body) if body else {}
if request.path == "/v1/jobs/describe":
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
dict(
job_id="job-1",
job_type="refresh_column",
job_state="DONE",
creation_ms=2000,
spec=dict(column="vec"),
result=dict(rows_assigned=1000000),
)
).encode()
)
elif request.path == "/v1/jobs/query_events":
event_payloads.append(payload)
request.send_response(200)
request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
request.end_headers()
request.wfile.write(events_body)
else:
request.send_response(404)
request.end_headers()
with mock_lancedb_connection(handler) as db:
job = db.open_job("job-1")
# Opening populates the handle in the same round trip.
assert job.state == "finished"
job.refresh()
assert job.job_type == "refresh_column"
assert job.creation_ms == 2000
assert job.spec == {"column": "vec"}
assert job.result == {"rows_assigned": 1000000}
assert job.failure is None
# The JSON payloads stay reachable, but as internal APIs.
assert json.loads(job._spec_json) == {"column": "vec"}
assert json.loads(job._result_json) == {"rows_assigned": 1000000}
# print() shows everything the handle knows and nothing it does not.
# print() lays every known field out on its own line, with the JSON
# payloads indented rather than crammed onto one line.
assert repr(job) == "\n".join(
[
"Job(",
" id='job-1',",
" state='finished',",
" job_type='refresh_column',",
" creation_ms=2000,",
" spec={",
' "column": "vec"',
" },",
" result={",
' "rows_assigned": 1000000',
" },",
")",
]
)
# Nothing it does not know shows up.
assert "failure" not in repr(job)
events = job.events(filter="state = 'claim_complete'", limit=500)
assert isinstance(events, pa.Table)
assert events.column("state").to_pylist() == ["claim_complete"]
# The handle supplies job_id; the caller only narrows the query.
assert event_payloads[-1] == {
"job_id": "job-1",
"limit": 500,
"filter": "state = 'claim_complete'",
}
+20
View File
@@ -4,6 +4,7 @@
import asyncio import asyncio
import copy import copy
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta from datetime import timedelta
import threading import threading
@@ -86,6 +87,25 @@ def test_s3_lifecycle(s3_bucket: str):
asyncio.run(test()) asyncio.run(test())
@pytest.mark.s3_test
def test_concurrent_open_table(s3_bucket: str):
uri = f"s3://{s3_bucket}/test_concurrent_open_table"
db = lancedb.connect(uri, storage_options=copy.copy(CONFIG))
db.create_table("test", pa.table({"x": [1, 2, 3]}))
num_workers = 32
barrier = threading.Barrier(num_workers)
def open_and_count(_):
barrier.wait()
return db.open_table("test").count_rows()
with ThreadPoolExecutor(max_workers=num_workers) as pool:
row_counts = list(pool.map(open_and_count, range(num_workers)))
assert row_counts == [3] * num_workers
@pytest.fixture() @pytest.fixture()
def kms_key(): def kms_key():
kms = get_boto3_client("kms", endpoint_url=CONFIG["aws_endpoint"]) kms = get_boto3_client("kms", endpoint_url=CONFIG["aws_endpoint"])

Some files were not shown because too many files have changed in this diff Show More