mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 07:12:27 +00:00
feat(node): accept an AbortSignal to cancel blob reads (#4350)
Closes #4288. Blob reads in Node can now be cancelled. `BlobFile.read`, `readRange` and `readRanges`, and `Table.fetchBlobs` and `fetchBlobFiles` take an optional `{ signal?: AbortSignal }` (new `BlobReadOptions` type). When the signal aborts, the call rejects with `signal.reason` (an `AbortError` unless the caller gave a reason) and the native future is dropped, so the read stops instead of running to completion. A signal that is already aborted rejects before any read starts. How it works: - On the Rust side, these five methods are now sync `#[napi]` methods that return `env.spawn_future(...)`. The future is wrapped in `futures::future::abortable`, and the abort handle is registered with napi's `AbortSignal::on_abort`. napi's `AbortSignal` is `Rc`-based, so it can't be an argument of an `async fn`, which is why the methods changed shape. The TypeScript signatures are unchanged apart from the new optional argument. - napi-rs installs its own `onabort` on the signal it receives, which would overwrite a handler the caller set. So the TypeScript wrapper passes a private `AbortController` signal to the native call for each request, forwards the caller's abort to it with `addEventListener(..., { once: true })`, and removes the listener when the call settles. Tests in `table.test.ts`: - an already aborted signal rejects on all five methods, and the handle still reads afterwards - a custom abort reason is what the call rejects with - a read with a signal that never aborts returns normally and leaves no `abort` listener on the signal - aborting a 32 MiB `readRange` right after it starts rejects with `AbortError`, and the same handle reads again afterwards The Node docs under `docs/src/js` are regenerated. I didn't add a test for where the `read()` cursor ends up after a mid-flight abort on a local table. The remote `BlobFile` documents that a cancelled read leaves the cursor unchanged, but I haven't confirmed the same for the native Lance handle, so the docs don't claim it.
This commit is contained in:
+75
-27
@@ -1,12 +1,15 @@
|
||||
// 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;
|
||||
|
||||
@@ -49,48 +52,73 @@ impl BlobFile {
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn read(&self, max_bytes: Option<BigInt>) -> napi::Result<Buffer> {
|
||||
pub fn read<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
max_bytes: Option<BigInt>,
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Buffer>> {
|
||||
self.ensure_open()?;
|
||||
let bytes = match max_bytes {
|
||||
None => self.inner.read().await,
|
||||
Some(max_bytes) => {
|
||||
let max_bytes = usize::try_from(parse_u64(max_bytes, "maxBytes")?)
|
||||
.map_err(|_| napi::Error::from_reason("maxBytes is too large"))?;
|
||||
self.inner.read_up_to(max_bytes).await
|
||||
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()))
|
||||
.map_err(|err| convert_error(&err))?;
|
||||
Ok(Buffer::from(bytes.as_ref()))
|
||||
})
|
||||
}
|
||||
|
||||
#[napi]
|
||||
pub async fn read_range(&self, start: BigInt, end: BigInt) -> napi::Result<Buffer> {
|
||||
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 bytes = self
|
||||
.inner
|
||||
.read_range(range)
|
||||
.await
|
||||
.map_err(|err| convert_error(&err))?;
|
||||
Ok(Buffer::from(bytes.as_ref()))
|
||||
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 async fn read_ranges(&self, ranges: Vec<BlobRange>) -> napi::Result<Vec<Buffer>> {
|
||||
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 buffers = self
|
||||
.inner
|
||||
.read_ranges(&ranges)
|
||||
.await
|
||||
.map_err(|err| convert_error(&err))?;
|
||||
Ok(buffers
|
||||
.iter()
|
||||
.map(|bytes| Buffer::from(bytes.as_ref()))
|
||||
.collect())
|
||||
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]
|
||||
@@ -124,6 +152,26 @@ impl BlobFile {
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs `fut` on the napi runtime. Aborting `signal` drops the future, which
|
||||
/// cancels in-flight reads.
|
||||
pub(crate) 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")?;
|
||||
|
||||
+29
-20
@@ -11,11 +11,12 @@ use lancedb::table::{
|
||||
FieldMetadataUpdate as LanceFieldMetadataUpdate, FtsToken as LanceDbFtsToken,
|
||||
NewColumnTransform, OptimizeAction, OptimizeOptions, Ref, Table as LanceDbTable,
|
||||
};
|
||||
use napi::Env;
|
||||
use napi::bindgen_prelude::*;
|
||||
use napi::threadsafe_function::{ThreadsafeFunction, ThreadsafeFunctionCallMode};
|
||||
use napi_derive::napi;
|
||||
|
||||
use crate::blob::{BlobFile, copy_blob_buffers, parse_row_ids};
|
||||
use crate::blob::{BlobFile, copy_blob_buffers, parse_row_ids, spawn_abortable};
|
||||
use crate::error::NapiErrorExt;
|
||||
use crate::index::Index;
|
||||
use crate::merge::NativeMergeInsertBuilder;
|
||||
@@ -340,36 +341,44 @@ impl Table {
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn fetch_blobs(
|
||||
pub fn fetch_blobs<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
column: String,
|
||||
row_ids: Vec<BigInt>,
|
||||
) -> napi::Result<Vec<Option<Buffer>>> {
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Vec<Option<Buffer>>>> {
|
||||
let row_ids = parse_row_ids(row_ids)?;
|
||||
let array = self
|
||||
.inner_ref()?
|
||||
.fetch_blobs(column.as_str(), &row_ids)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(copy_blob_buffers(array))
|
||||
let table = self.inner_ref()?.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let array = table
|
||||
.fetch_blobs(column.as_str(), &row_ids)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(copy_blob_buffers(array))
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn fetch_blob_files(
|
||||
pub fn fetch_blob_files<'env>(
|
||||
&self,
|
||||
env: &'env Env,
|
||||
column: String,
|
||||
row_ids: Vec<BigInt>,
|
||||
) -> napi::Result<Vec<Option<BlobFile>>> {
|
||||
signal: Option<AbortSignal>,
|
||||
) -> napi::Result<PromiseRaw<'env, Vec<Option<BlobFile>>>> {
|
||||
let row_ids = parse_row_ids(row_ids)?;
|
||||
let files = self
|
||||
.inner_ref()?
|
||||
.fetch_blob_files(column.as_str(), &row_ids)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.map(|file| file.map(BlobFile::new))
|
||||
.collect())
|
||||
let table = self.inner_ref()?.clone();
|
||||
spawn_abortable(env, signal, async move {
|
||||
let files = table
|
||||
.fetch_blob_files(column.as_str(), &row_ids)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(files
|
||||
.into_iter()
|
||||
.map(|file| file.map(BlobFile::new))
|
||||
.collect())
|
||||
})
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
|
||||
Reference in New Issue
Block a user