mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
Existing Rust and Python blob range calls must keep working when only the SDK is upgraded. Add negotiated batched range reads across Rust, Python sync/async and TypeScript, preserving ordered NULLs, duplicates and branch/version reads with splitting and reassembly at 1,024 ranges or 64 MiB per request. Only a successful describe response advertising `x-lancedb-blob-ranges: 1` selects the batched endpoint. Missing or unknown capability values select the existing descriptor-query plus BlobFile Range GET route before the operation. Endpoint failures never switch protocols. HTTP 400 preserves the server message and request ID as Rust `InvalidInput` / Python `ValueError`; other errors retain their existing semantics. The operation binds both numeric version and immutable snapshot identity from its producing query or first range response. Every later partition and oversized-range piece sends that original identity and requires an equal response identity before decoding. Same-name table recreation therefore fails explicitly instead of returning mixed old/new payloads; no partial array is returned on later failure. Targets `main`. The read-buffering prerequisite #4979, safe-write prerequisite #4977 and deferred-query identity prerequisite #4973 are merged into main; #5000 also supplies the merged SQL timeout-test prerequisite. Server support is [Sophon #10219](https://github.com/lancedb/sophon/pull/10219), including #10211. Exact SDK `1497b508324662180086a4d1e0a6eb9e0b3970db` and server `f39712c2c150b220eb831e00f6663c9eef32dce5` passed a real local SDK → REST → PE experiment: one Rust harness test, 14 assertion groups, 38 REST range requests and 34 PE RPCs. All four sync/async × warm/cold table-recreation cases failed explicitly before the second descriptor lookup or PE dispatch, with no mixed payload or partial result. The other groups retained 1,025-range success, 70 MiB reassembly, branch/version reads, overwrite snapshot consistency, error behavior and TypeScript buffer ownership. Bindings were rebuilt and their loaded hashes verified. This is local interoperability evidence, not Cloud, mixed-fleet or deployed header-forwarding acceptance. The server dependency upgrade, External/cache/URI-policy acceptance and validated rollout remain required. Enforcing PE support must precede QN and SDK enablement; identity does not retain versions or freeze External objects. This PR does not claim deployed support or complete ENT-3100/GA acceptance. Published documentation retains the Early access badge and released Enterprise instructions, including explicit fetches for copying. The new Enterprise `blob_mode="bytes"` guidance, snapshot-bound copy example, future streaming restrictions and GA wording remain source-only MDX drafts until release. The correction distinguishing released Enterprise SQL bytes from SDK descriptors remains visible. Documentation validation checked comment boundaries, visible instructions, Python example syntax and whitespace; the site was not built. The main integration keeps these release gates unchanged. Main integration at `0491d9bdcc65ce5d52ea93e1083c89e951ea4f9d` normally merges main `9b13e8f72530c8b1950c09685f6920711154a539`, including its Lance beta.18 dependency. Focused current-source validation passed: 493 remote-table/query/Blob/IPC/write-progress Rust tests, 23 shared create/write-body tests, 59 native Blob integration tests and 7 public HTTP range tests. Rebuilt Python passed 468 selected tests (one optional PyAV test skipped), and rebuilt TypeScript passed 194 selected tests across four suites; lint/format, TypeScript compilation and API documentation generation passed. Shared tests include actual HTTP multipart failures, acknowledgment boundaries, dictionary compaction and native Blob compaction. The 598 unrelated Jest cases excluded by the focused shared-suite selector were not run. The retained 14-group joint REST/PE experiment used the earlier exact candidates stated above. Operation-identity/range/IPC code is unchanged, but dependencies and shared write interfaces changed with main; the new local checks validate the SDK combination and do not constitute a fresh final-head SDK/server joint, Cloud or rollout run. Early access and every remaining server/External/cache/policy/dependency/rollout condition remain in force. CI snapshot on this head (2026-11-12 18:30 UTC): `build-no-lock` failed in the standalone `ci/tests/test_build_no_lock.py` fixture at the retained-rlib assertion, before the actual SDK no-lock build. The fixture, helper and Rust workflow are byte-identical to main; the fixture does not load SDK source. The underlying CI/environment cause is not diagnosed here. Other jobs remained pending/running at the snapshot, so CI is not reported as passing. Failure: https://github.com/lancedb/lancedb/actions/runs/43280277756/job/131399418284.
249 lines
7.0 KiB
Rust
249 lines
7.0 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
use std::future::Future;
|
|
use std::ops::Range;
|
|
use std::sync::Arc;
|
|
use std::sync::atomic::{AtomicBool, Ordering};
|
|
|
|
use arrow_array::{Array, LargeBinaryArray};
|
|
use futures::future::abortable;
|
|
use lancedb::blob::BlobFile as LanceBlobFile;
|
|
use napi::Env;
|
|
use napi::bindgen_prelude::*;
|
|
use napi_derive::napi;
|
|
|
|
use crate::error::convert_error;
|
|
|
|
#[napi(object)]
|
|
pub struct BlobRange {
|
|
pub start: BigInt,
|
|
pub end: BigInt,
|
|
}
|
|
|
|
#[napi(object)]
|
|
pub struct BlobRangeRequest {
|
|
pub row_id: BigInt,
|
|
pub offset: BigInt,
|
|
pub length: BigInt,
|
|
}
|
|
|
|
impl BlobRangeRequest {
|
|
pub(crate) fn parse(self) -> napi::Result<lancedb::blob::BlobRangeRequest> {
|
|
let row_id = parse_u64(self.row_id, "row id")?;
|
|
let offset = parse_u64(self.offset, "offset")?;
|
|
let length = parse_u64(self.length, "length")?;
|
|
offset
|
|
.checked_add(length)
|
|
.ok_or_else(|| napi::Error::from_reason("blob range offset + length overflows u64"))?;
|
|
Ok(lancedb::blob::BlobRangeRequest::new(row_id, offset, length))
|
|
}
|
|
}
|
|
|
|
#[napi]
|
|
pub struct BlobFile {
|
|
inner: Arc<LanceBlobFile>,
|
|
closed: AtomicBool,
|
|
}
|
|
|
|
impl BlobFile {
|
|
pub(crate) fn new(inner: LanceBlobFile) -> Self {
|
|
Self {
|
|
inner: Arc::new(inner),
|
|
closed: AtomicBool::new(false),
|
|
}
|
|
}
|
|
|
|
fn ensure_open(&self) -> napi::Result<()> {
|
|
if self.closed.load(Ordering::Acquire) {
|
|
Err(napi::Error::from_reason("blob file is already closed"))
|
|
} else {
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
#[napi]
|
|
impl BlobFile {
|
|
#[napi]
|
|
pub fn size(&self) -> BigInt {
|
|
BigInt::from(self.inner.size())
|
|
}
|
|
|
|
#[napi]
|
|
pub fn read<'env>(
|
|
&self,
|
|
env: &'env Env,
|
|
max_bytes: Option<BigInt>,
|
|
signal: Option<AbortSignal>,
|
|
) -> napi::Result<PromiseRaw<'env, Buffer>> {
|
|
self.ensure_open()?;
|
|
let max_bytes = max_bytes
|
|
.map(|max_bytes| {
|
|
usize::try_from(parse_u64(max_bytes, "maxBytes")?)
|
|
.map_err(|_| napi::Error::from_reason("maxBytes is too large"))
|
|
})
|
|
.transpose()?;
|
|
let inner = self.inner.clone();
|
|
spawn_abortable(env, signal, async move {
|
|
let bytes = match max_bytes {
|
|
None => inner.read().await,
|
|
Some(max_bytes) => inner.read_up_to(max_bytes).await,
|
|
}
|
|
.map_err(|err| convert_error(&err))?;
|
|
Ok(Buffer::from(bytes.as_ref()))
|
|
})
|
|
}
|
|
|
|
#[napi]
|
|
pub fn read_range<'env>(
|
|
&self,
|
|
env: &'env Env,
|
|
start: BigInt,
|
|
end: BigInt,
|
|
signal: Option<AbortSignal>,
|
|
) -> napi::Result<PromiseRaw<'env, Buffer>> {
|
|
self.ensure_open()?;
|
|
let range = bigint_range(start, end)?;
|
|
let inner = self.inner.clone();
|
|
spawn_abortable(env, signal, async move {
|
|
let bytes = inner
|
|
.read_range(range)
|
|
.await
|
|
.map_err(|err| convert_error(&err))?;
|
|
Ok(Buffer::from(bytes.as_ref()))
|
|
})
|
|
}
|
|
|
|
#[napi]
|
|
pub fn read_ranges<'env>(
|
|
&self,
|
|
env: &'env Env,
|
|
ranges: Vec<BlobRange>,
|
|
signal: Option<AbortSignal>,
|
|
) -> napi::Result<PromiseRaw<'env, Vec<Buffer>>> {
|
|
self.ensure_open()?;
|
|
let ranges = ranges
|
|
.into_iter()
|
|
.map(|range| bigint_range(range.start, range.end))
|
|
.collect::<napi::Result<Vec<_>>>()?;
|
|
let inner = self.inner.clone();
|
|
spawn_abortable(env, signal, async move {
|
|
let buffers = inner
|
|
.read_ranges(&ranges)
|
|
.await
|
|
.map_err(|err| convert_error(&err))?;
|
|
Ok(buffers
|
|
.iter()
|
|
.map(|bytes| Buffer::from(bytes.as_ref()))
|
|
.collect())
|
|
})
|
|
}
|
|
|
|
#[napi]
|
|
pub async fn seek(&self, position: BigInt) -> napi::Result<()> {
|
|
self.ensure_open()?;
|
|
let position = parse_u64(position, "position")?;
|
|
self.inner
|
|
.seek(position)
|
|
.await
|
|
.map_err(|err| convert_error(&err))
|
|
}
|
|
|
|
#[napi]
|
|
pub async fn tell(&self) -> napi::Result<BigInt> {
|
|
self.ensure_open()?;
|
|
let position = self.inner.tell().await.map_err(|err| convert_error(&err))?;
|
|
Ok(BigInt::from(position))
|
|
}
|
|
|
|
#[napi]
|
|
pub async fn close(&self) -> napi::Result<()> {
|
|
if self.closed.swap(true, Ordering::AcqRel) {
|
|
return Ok(());
|
|
}
|
|
self.inner.close().await.map_err(|err| convert_error(&err))
|
|
}
|
|
|
|
#[napi]
|
|
pub fn is_closed(&self) -> bool {
|
|
self.closed.load(Ordering::Acquire)
|
|
}
|
|
}
|
|
|
|
/// Runs `fut` on the napi runtime. Aborting `signal` drops the future, which
|
|
/// cancels in-flight reads.
|
|
pub fn spawn_abortable<'env, T>(
|
|
env: &'env Env,
|
|
signal: Option<AbortSignal>,
|
|
fut: impl Future<Output = napi::Result<T>> + Send + 'static,
|
|
) -> napi::Result<PromiseRaw<'env, T>>
|
|
where
|
|
T: ToNapiValue + Send + 'static,
|
|
{
|
|
let (fut, handle) = abortable(fut);
|
|
if let Some(signal) = signal {
|
|
signal.on_abort(move || handle.abort());
|
|
}
|
|
env.spawn_future(async move {
|
|
fut.await
|
|
.unwrap_or_else(|_| Err(napi::Error::from_reason("blob read aborted")))
|
|
})
|
|
}
|
|
|
|
fn bigint_range(start: BigInt, end: BigInt) -> napi::Result<Range<u64>> {
|
|
let start = parse_u64(start, "start")?;
|
|
let end = parse_u64(end, "end")?;
|
|
if start > end {
|
|
return Err(napi::Error::from_reason(format!(
|
|
"invalid blob range: start ({start}) > end ({end})"
|
|
)));
|
|
}
|
|
Ok(start..end)
|
|
}
|
|
|
|
fn parse_u64(value: BigInt, name: &str) -> napi::Result<u64> {
|
|
let (negative, value, lossless) = value.get_u64();
|
|
if negative {
|
|
return Err(napi::Error::from_reason(format!(
|
|
"{name} cannot be negative"
|
|
)));
|
|
}
|
|
if !lossless {
|
|
return Err(napi::Error::from_reason(format!(
|
|
"{name} is too large to fit in u64"
|
|
)));
|
|
}
|
|
Ok(value)
|
|
}
|
|
|
|
pub fn parse_row_ids(row_ids: Vec<BigInt>) -> napi::Result<Vec<u64>> {
|
|
row_ids
|
|
.into_iter()
|
|
.map(|id| parse_u64(id, "row id"))
|
|
.collect()
|
|
}
|
|
|
|
pub fn copy_blob_buffers(array: LargeBinaryArray) -> napi::Result<Vec<Option<Buffer>>> {
|
|
let allocation_error =
|
|
|error| napi::Error::from_reason(format!("cannot allocate blob result: {error}"));
|
|
let mut result = Vec::new();
|
|
result
|
|
.try_reserve_exact(array.len())
|
|
.map_err(allocation_error)?;
|
|
for value in array.iter() {
|
|
let buffer = value
|
|
.map(|value| {
|
|
let mut bytes = Vec::new();
|
|
bytes
|
|
.try_reserve_exact(value.len())
|
|
.map_err(allocation_error)?;
|
|
bytes.extend_from_slice(value);
|
|
Ok::<_, napi::Error>(Buffer::from(bytes))
|
|
})
|
|
.transpose()?;
|
|
result.push(buffer);
|
|
}
|
|
Ok(result)
|
|
}
|