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:
Wyatt Alt
2026-11-06 09:19:27 -08:00
committed by GitHub
parent d379bbb722
commit 2af85bea89
18 changed files with 974 additions and 53 deletions
+74
View File
@@ -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) => {
+11
View File
@@ -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 }]);
+1
View File
@@ -56,6 +56,7 @@ export {
MergeResult,
AddResult,
AddColumnsResult,
RefreshColumnOptions,
RefreshColumnResult,
FunctionErrors,
FunctionErrorsOptions,
+35 -7
View File
@@ -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
View File
@@ -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)]