mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-13 14:16:54 +00:00
feat: expose the remaining refresh options on the SDKs (#4890)
The server's backfill request takes a row filter, failure limits, per-call timeouts and worker sizing, and SQL exposes all of them through `REFRESH COLUMN ... WHERE ... WITH (...)`, but the typed SDKs could send only batch_size, GPU placement and the retry and error policies. Anyone needing a filtered refresh or a bounded skip policy had to drop to SQL. This adds `where`, `max_failed_fraction`, `max_failed_rows`, `row_timeout_seconds`, `init_timeout_seconds`, `num_workers` and `max_executor_processes_per_worker` to Python, Rust and Node, passed through unchanged. `RefreshColumnOptions` now serializes straight into the request body, so the three SDKs share one producer. Node gets a `RefreshColumnOptions` object, which also carries the options it lacked before. A LanceDB Embedded refresh rejects every option other than batch_size and GPU placement, naming the option.
This commit is contained in:
@@ -647,6 +647,80 @@ describe("remote connection", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards refresh options to the backfill request", async () => {
|
||||
const bodies: unknown[] = [];
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
const path = req.url ?? "";
|
||||
if (path.endsWith("/describe/")) {
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
JSON.stringify({
|
||||
name: "docs",
|
||||
version: 1,
|
||||
schema: { fields: [] },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (path === "/v1/table/docs/backfill_column") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
bodies.push(JSON.parse(body));
|
||||
res
|
||||
.writeHead(202, { "Content-Type": "application/json" })
|
||||
.end('{"job_id": "j-9"}');
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("docs");
|
||||
const job = await table.refreshColumnAsync("embedding", {
|
||||
where: "id % 2 = 0",
|
||||
batchSize: 16,
|
||||
gpuClass: "a10g",
|
||||
gpuCount: 2,
|
||||
errorPolicy: "skip",
|
||||
maxFailedFraction: 0.01,
|
||||
maxFailedRows: 50,
|
||||
rowTimeoutSeconds: 30,
|
||||
initTimeoutSeconds: 120,
|
||||
maxExecutorProcessesPerWorker: 2,
|
||||
});
|
||||
expect(job.id).toBe("j-9");
|
||||
await table.refreshColumnAsync("embedding");
|
||||
// Invalid options reject before any request is sent.
|
||||
for (const [options, message] of [
|
||||
[{ maxFailedRows: -1 }, "maxFailedRows"],
|
||||
[{ errorPolicy: "retry" }, "errorPolicy"],
|
||||
[{ where: " " }, "where"],
|
||||
[{ maxFailedFraction: 2 }, "max_failed_fraction"],
|
||||
[{ rowTimeoutSeconds: 0 }, "row_timeout_seconds"],
|
||||
[{ numWorkers: 0 }, "num_workers"],
|
||||
[{ gpuCount: 0 }, "gpu_count"],
|
||||
] as const) {
|
||||
await expect(
|
||||
table.refreshColumnAsync("embedding", options),
|
||||
).rejects.toThrow(message);
|
||||
}
|
||||
},
|
||||
);
|
||||
expect(bodies).toEqual([
|
||||
JSON.parse(
|
||||
`{"column": "embedding", "where": "id % 2 = 0", "batch_size": 16,
|
||||
"gpu_class": "a10g", "gpu_count": 2, "error_policy": "skip",
|
||||
"max_failed_fraction": 0.01, "max_failed_rows": 50,
|
||||
"row_timeout_seconds": 30, "init_timeout_seconds": 120,
|
||||
"max_executor_processes_per_worker": 2}`,
|
||||
),
|
||||
{ column: "embedding" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces JSON server errors from remote table operations", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
|
||||
@@ -5590,6 +5590,17 @@ describe("computed columns", () => {
|
||||
expect(rows.map((r) => r.doubled).sort()).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it("rejects Enterprise refresh options on local tables", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("refresh_options_local", [{ x: 1 }]);
|
||||
await expect(table.refreshColumn("x", { where: "x > 0" })).rejects.toThrow(
|
||||
"where requires a LanceDB Enterprise refresh",
|
||||
);
|
||||
await expect(
|
||||
table.refreshColumnAsync("x", { errorPolicy: "skip" }),
|
||||
).rejects.toThrow("error_policy requires a LanceDB Enterprise refresh");
|
||||
});
|
||||
|
||||
it("records Function errors only on remote tables", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("errors_local", [{ x: 1 }]);
|
||||
|
||||
@@ -56,6 +56,7 @@ export {
|
||||
MergeResult,
|
||||
AddResult,
|
||||
AddColumnsResult,
|
||||
RefreshColumnOptions,
|
||||
RefreshColumnResult,
|
||||
FunctionErrors,
|
||||
FunctionErrorsOptions,
|
||||
|
||||
+35
-7
@@ -38,6 +38,7 @@ import {
|
||||
Branches as NativeBranches,
|
||||
OptimizeStats,
|
||||
RTEStats,
|
||||
RefreshColumnOptions,
|
||||
RefreshColumnResult,
|
||||
RefreshMaterializedViewResult,
|
||||
TableStatistics,
|
||||
@@ -671,10 +672,15 @@ export abstract class Table {
|
||||
* everything else is left as it is. LanceDB Embedded only: in LanceDB
|
||||
* Enterprise a refresh runs as a server job, through {@link Table#refreshColumnAsync}.
|
||||
* @param {string} column The name of the computed column to fill.
|
||||
* @param {RefreshColumnOptions} options Refresh options, all LanceDB
|
||||
* Enterprise only; see {@link Table#refreshColumnAsync}.
|
||||
* @returns {Promise<RefreshColumnResult>} A promise that resolves to the
|
||||
* number of rows filled and the new version number of the table.
|
||||
*/
|
||||
abstract refreshColumn(column: string): Promise<RefreshColumnResult>;
|
||||
abstract refreshColumn(
|
||||
column: string,
|
||||
options?: RefreshColumnOptions,
|
||||
): Promise<RefreshColumnResult>;
|
||||
|
||||
/**
|
||||
* Like {@link Table#refreshColumn}, but returns a handle to the refresh
|
||||
@@ -689,14 +695,30 @@ export abstract class Table {
|
||||
* than failing the job. In LanceDB Embedded the job runs in-process; in
|
||||
* LanceDB Enterprise it is the server's backfill job.
|
||||
* @param {string} column The name of the computed column to fill.
|
||||
* @param {RefreshColumnOptions} options Refresh options, mirroring the SQL
|
||||
* `REFRESH COLUMN ... WHERE ... WITH (...)` form: `where` narrows the rows,
|
||||
* `errorPolicy`, `maxFailedFraction`, `maxFailedRows`, `rowTimeoutSeconds`
|
||||
* and `initTimeoutSeconds` govern failing rows, and `batchSize`,
|
||||
* `gpuClass`, `gpuCount`, `numWorkers` and `maxExecutorProcessesPerWorker`
|
||||
* size the workers. All are LanceDB Enterprise only; a LanceDB Embedded
|
||||
* refresh rejects every option other than `batchSize`, `gpuClass` and
|
||||
* `gpuCount`.
|
||||
* @example
|
||||
* ```ts
|
||||
* const job = await table.refreshColumnAsync("doubled");
|
||||
* const job = await table.refreshColumnAsync("embedding", {
|
||||
* where: "published_at > '2026-01-01'",
|
||||
* errorPolicy: "skip",
|
||||
* maxFailedFraction: 0.01,
|
||||
* rowTimeoutSeconds: 30,
|
||||
* });
|
||||
* await job.wait();
|
||||
* console.log(await job.status()); // "finished"
|
||||
* ```
|
||||
*/
|
||||
abstract refreshColumnAsync(column: string): Promise<Job>;
|
||||
abstract refreshColumnAsync(
|
||||
column: string,
|
||||
options?: RefreshColumnOptions,
|
||||
): Promise<Job>;
|
||||
|
||||
/**
|
||||
* The per-row errors Function refreshes recorded on this table.
|
||||
@@ -1472,12 +1494,18 @@ export class LocalTable extends Table {
|
||||
throw new Error("Invalid input type for addColumns");
|
||||
}
|
||||
|
||||
async refreshColumn(column: string): Promise<RefreshColumnResult> {
|
||||
return await this.inner.refreshColumn(column);
|
||||
async refreshColumn(
|
||||
column: string,
|
||||
options?: RefreshColumnOptions,
|
||||
): Promise<RefreshColumnResult> {
|
||||
return await this.inner.refreshColumn(column, options);
|
||||
}
|
||||
|
||||
async refreshColumnAsync(column: string): Promise<Job> {
|
||||
return new Job(await this.inner.refreshColumnAsync(column));
|
||||
async refreshColumnAsync(
|
||||
column: string,
|
||||
options?: RefreshColumnOptions,
|
||||
): Promise<Job> {
|
||||
return new Job(await this.inner.refreshColumnAsync(column, options));
|
||||
}
|
||||
|
||||
async functionErrors(
|
||||
|
||||
+84
-4
@@ -421,20 +421,30 @@ impl Table {
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn refresh_column(&self, column: String) -> napi::Result<RefreshColumnResult> {
|
||||
pub async fn refresh_column(
|
||||
&self,
|
||||
column: String,
|
||||
options: Option<RefreshColumnOptions>,
|
||||
) -> napi::Result<RefreshColumnResult> {
|
||||
let options = options.unwrap_or_default().try_into()?;
|
||||
let res = self
|
||||
.inner_ref()?
|
||||
.refresh_column(column)
|
||||
.refresh_column_with_options(column, options)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(res.into())
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn refresh_column_async(&self, column: String) -> napi::Result<crate::job::Job> {
|
||||
pub async fn refresh_column_async(
|
||||
&self,
|
||||
column: String,
|
||||
options: Option<RefreshColumnOptions>,
|
||||
) -> napi::Result<crate::job::Job> {
|
||||
let options = options.unwrap_or_default().try_into()?;
|
||||
let job = self
|
||||
.inner_ref()?
|
||||
.refresh_column_async(column)
|
||||
.refresh_column_async_with_options(column, options)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(crate::job::Job::new(job))
|
||||
@@ -1515,6 +1525,76 @@ pub struct FunctionErrorsOptions {
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// Options for `Table.refreshColumn` and `Table.refreshColumnAsync`. Every
|
||||
/// option is LanceDB Enterprise only; a LanceDB Embedded refresh ignores
|
||||
/// `batchSize`, `gpuClass` and `gpuCount` and rejects the rest.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct RefreshColumnOptions {
|
||||
/// Only refresh the rows matching this SQL filter.
|
||||
#[napi(js_name = "where")]
|
||||
pub where_clause: Option<String>,
|
||||
/// Maximum rows per Function call; must be greater than zero.
|
||||
pub batch_size: Option<u32>,
|
||||
/// GPU class for Function workers.
|
||||
pub gpu_class: Option<String>,
|
||||
/// Positive aggregate GPU budget for Function workers.
|
||||
pub gpu_count: Option<u32>,
|
||||
/// `"fail"` (default) stops on a row error; `"skip"` writes null and
|
||||
/// records the error, subject to the failure limits.
|
||||
pub error_policy: Option<String>,
|
||||
/// Under a skip policy, fail the job once more than this share of rows
|
||||
/// (0 to 1) has failed.
|
||||
pub max_failed_fraction: Option<f64>,
|
||||
/// Under a skip policy, fail the job once more than this many rows have
|
||||
/// failed.
|
||||
pub max_failed_rows: Option<i64>,
|
||||
/// Deadline for one Function call, in seconds.
|
||||
pub row_timeout_seconds: Option<f64>,
|
||||
/// Deadline for Function initialization, in seconds.
|
||||
pub init_timeout_seconds: Option<f64>,
|
||||
/// Worker count for an expression column; Function columns reject it.
|
||||
pub num_workers: Option<u32>,
|
||||
/// Cap on executor processes per Function worker.
|
||||
pub max_executor_processes_per_worker: Option<u32>,
|
||||
}
|
||||
|
||||
impl TryFrom<RefreshColumnOptions> for lancedb::table::RefreshColumnOptions {
|
||||
type Error = napi::Error;
|
||||
|
||||
fn try_from(value: RefreshColumnOptions) -> napi::Result<Self> {
|
||||
let mut options = Self::default();
|
||||
options.where_clause = value.where_clause;
|
||||
options.batch_size = value.batch_size.map(|size| size as usize);
|
||||
options.gpu_class = value.gpu_class;
|
||||
options.gpu_count = value.gpu_count;
|
||||
options.error_policy = match value.error_policy.as_deref() {
|
||||
None => None,
|
||||
Some("fail") => Some(lancedb::function::FunctionErrorPolicy::Fail),
|
||||
Some("skip") => Some(lancedb::function::FunctionErrorPolicy::Skip),
|
||||
Some(other) => {
|
||||
return Err(napi::Error::from_reason(format!(
|
||||
"errorPolicy must be \"fail\" or \"skip\", got \"{other}\""
|
||||
)));
|
||||
}
|
||||
};
|
||||
options.max_failed_fraction = value.max_failed_fraction;
|
||||
options.max_failed_rows = value
|
||||
.max_failed_rows
|
||||
.map(|rows| {
|
||||
u64::try_from(rows).map_err(|_| {
|
||||
napi::Error::from_reason("maxFailedRows must be a non-negative integer")
|
||||
})
|
||||
})
|
||||
.transpose()?;
|
||||
options.row_timeout_seconds = value.row_timeout_seconds;
|
||||
options.init_timeout_seconds = value.init_timeout_seconds;
|
||||
options.num_workers = value.num_workers.map(|workers| workers as usize);
|
||||
options.max_executor_processes_per_worker = value.max_executor_processes_per_worker;
|
||||
Ok(options)
|
||||
}
|
||||
}
|
||||
|
||||
/// One row a Function refresh skipped, as the server recorded it.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
|
||||
Reference in New Issue
Block a user