mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-22 13:05:48 +00:00
feat: list a table's per-row Function errors from the client (#4208)
A refresh running under a skip policy records each row it skipped, with the failing input and the error, but the client could not read that store: the server exposes it over SQL and, since recently, a REST route. A user who hit per-row failures still had to open a SQL session. `Table::function_errors` calls the route. The listing is table-addressed with optional job and column filters, the same addressing the SQL surface uses, so the two cannot disagree about what a table's errors are. The two non-record signals come back as their own fields rather than as rows: capped-fragment summaries, and whether the listing stopped at its limit. Local tables refuse rather than answer with an empty list. Python and Node expose the same call, with the same optional filters.
This commit is contained in:
@@ -195,6 +195,66 @@ describe("remote connection", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("lists the rows a Function refresh skipped", 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/errors") {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += chunk;
|
||||
});
|
||||
req.on("end", () => {
|
||||
bodies.push(JSON.parse(body));
|
||||
res.writeHead(200, { "Content-Type": "application/json" }).end(
|
||||
`{"records": [{"job_id": "j-7", "fragment_id": 3, "row_offset": 9,
|
||||
"column": "embedding", "function": "embed", "function_version": "2",
|
||||
"table_version": 11, "error_type": "ValueError",
|
||||
"error_message": "bad input 'x'", "created_at_millis": 1700000000000}],
|
||||
"fragments": [{"job_id": "j-7", "fragment_id": 4, "rows_skipped": 500,
|
||||
"rows_recorded": 100}], "truncated": true}`,
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
},
|
||||
async (db) => {
|
||||
const table = await db.openTable("docs");
|
||||
const errors = await table.functionErrors({
|
||||
jobId: "j-7",
|
||||
column: "embedding",
|
||||
limit: 2,
|
||||
});
|
||||
expect(errors.truncated).toBe(true);
|
||||
expect(errors.records.map((r) => r.errorMessage)).toEqual([
|
||||
"bad input 'x'",
|
||||
]);
|
||||
expect(errors.records[0].rowOffset).toBe(9);
|
||||
expect(errors.fragments[0].rowsSkipped).toBe(500);
|
||||
await table.functionErrors();
|
||||
await expect(table.functionErrors({ limit: -1 })).rejects.toThrow(
|
||||
"limit must be a non-negative integer",
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(bodies).toEqual([
|
||||
JSON.parse('{"job_id": "j-7", "column": "embedding", "limit": 2}'),
|
||||
{},
|
||||
]);
|
||||
});
|
||||
|
||||
it("surfaces JSON server errors from remote table operations", async () => {
|
||||
await withMockDatabase(
|
||||
(req, res) => {
|
||||
|
||||
@@ -4358,6 +4358,14 @@ describe("computed columns", () => {
|
||||
expect(rows.map((r) => r.doubled).sort()).toEqual([2, 4]);
|
||||
});
|
||||
|
||||
it("records Function errors only on remote tables", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("errors_local", [{ x: 1 }]);
|
||||
await expect(table.functionErrors()).rejects.toThrow(
|
||||
"LanceDB Cloud and Enterprise",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns a job handle from refreshColumnAsync", async () => {
|
||||
const db = await connect(tmpDir.name);
|
||||
const table = await db.createTable("computed_job", [{ x: 1 }, { x: 2 }]);
|
||||
|
||||
@@ -56,6 +56,10 @@ export {
|
||||
AddResult,
|
||||
AddColumnsResult,
|
||||
RefreshColumnResult,
|
||||
FunctionErrors,
|
||||
FunctionErrorsOptions,
|
||||
FunctionErrorRecord,
|
||||
FunctionErrorFragment,
|
||||
RefreshMaterializedViewResult,
|
||||
AlterColumnsResult,
|
||||
UpdateFieldMetadataResult,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { BlobFile } from "./blob";
|
||||
import { EmbeddingFunctionConfig, getRegistry } from "./embedding/registry";
|
||||
import { IndexOptions } from "./indices";
|
||||
import { Job } from "./job";
|
||||
import { validateNonNegativeInteger } from "./materialized_view";
|
||||
import { MergeInsertBuilder } from "./merge";
|
||||
import {
|
||||
AddColumnsResult,
|
||||
@@ -30,6 +31,8 @@ import {
|
||||
BranchContents,
|
||||
DeleteResult,
|
||||
DropColumnsResult,
|
||||
FunctionErrors,
|
||||
FunctionErrorsOptions,
|
||||
IndexConfig,
|
||||
IndexStatistics,
|
||||
LsmStats,
|
||||
@@ -636,6 +639,27 @@ export abstract class Table {
|
||||
*/
|
||||
abstract refreshColumnAsync(column: string): Promise<Job>;
|
||||
|
||||
/**
|
||||
* The per-row errors Function refreshes recorded on this table.
|
||||
*
|
||||
* A refresh running under a skip policy records each row it skipped with
|
||||
* the input that failed and the error. This lists those records, newest
|
||||
* job first, plus a summary for any fragment whose per-row detail was
|
||||
* capped. LanceDB Cloud and Enterprise only; reading errors needs read
|
||||
* access to the table, since a message carries the value that failed.
|
||||
* @param {FunctionErrorsOptions} options Optional filters: `jobId`,
|
||||
* `column`, and `limit` (server default 10000, cap 100000).
|
||||
* @returns {Promise<FunctionErrors>} The records, the capped fragments,
|
||||
* and whether the listing stopped at its limit.
|
||||
* @example
|
||||
* ```ts
|
||||
* const { records, truncated } = await table.functionErrors({ column: "embedding" });
|
||||
* ```
|
||||
*/
|
||||
abstract functionErrors(
|
||||
options?: FunctionErrorsOptions,
|
||||
): Promise<FunctionErrors>;
|
||||
|
||||
/**
|
||||
* Recompute this table's contents from its materialized-view definition.
|
||||
*
|
||||
@@ -1363,6 +1387,13 @@ export class LocalTable extends Table {
|
||||
return new Job(await this.inner.refreshColumnAsync(column));
|
||||
}
|
||||
|
||||
async functionErrors(
|
||||
options?: FunctionErrorsOptions,
|
||||
): Promise<FunctionErrors> {
|
||||
validateNonNegativeInteger(options?.limit, "limit");
|
||||
return await this.inner.functionErrors(options);
|
||||
}
|
||||
|
||||
async refreshMaterializedView(
|
||||
full?: boolean,
|
||||
sourceVersion?: number,
|
||||
|
||||
@@ -427,6 +427,32 @@ impl Table {
|
||||
Ok(crate::job::Job::new(job))
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn function_errors(
|
||||
&self,
|
||||
options: Option<FunctionErrorsOptions>,
|
||||
) -> napi::Result<FunctionErrors> {
|
||||
let options = options.unwrap_or_default();
|
||||
let limit = options
|
||||
.limit
|
||||
.map(|limit| {
|
||||
usize::try_from(limit)
|
||||
.map_err(|_| napi::Error::from_reason("limit must be a non-negative integer"))
|
||||
})
|
||||
.transpose()?;
|
||||
let request = lancedb::function::FunctionErrorsRequest {
|
||||
job_id: options.job_id,
|
||||
column: options.column,
|
||||
limit,
|
||||
};
|
||||
let errors = self
|
||||
.inner_ref()?
|
||||
.function_errors(request)
|
||||
.await
|
||||
.default_error()?;
|
||||
Ok(errors.into())
|
||||
}
|
||||
|
||||
#[napi(catch_unwind)]
|
||||
pub async fn refresh_materialized_view(
|
||||
&self,
|
||||
@@ -1476,6 +1502,97 @@ pub struct RefreshColumnResult {
|
||||
pub version: i64,
|
||||
}
|
||||
|
||||
/// Which per-row Function errors to list; every filter is optional.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct FunctionErrorsOptions {
|
||||
/// Only errors recorded by this job.
|
||||
pub job_id: Option<String>,
|
||||
/// Only errors on this column.
|
||||
pub column: Option<String>,
|
||||
/// At most this many records (server default 10000, cap 100000).
|
||||
pub limit: Option<i64>,
|
||||
}
|
||||
|
||||
/// One row a Function refresh skipped, as the server recorded it.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FunctionErrorRecord {
|
||||
pub job_id: String,
|
||||
pub fragment_id: i64,
|
||||
/// The row's offset within the fragment; absent when the fragment's
|
||||
/// detail was capped.
|
||||
pub row_offset: Option<i64>,
|
||||
pub column: String,
|
||||
pub function: String,
|
||||
pub function_version: String,
|
||||
pub table_version: i64,
|
||||
pub error_type: String,
|
||||
pub error_message: String,
|
||||
pub created_at_millis: i64,
|
||||
}
|
||||
|
||||
impl From<lancedb::function::FunctionErrorRecord> for FunctionErrorRecord {
|
||||
fn from(record: lancedb::function::FunctionErrorRecord) -> Self {
|
||||
Self {
|
||||
job_id: record.job_id,
|
||||
fragment_id: record.fragment_id as i64,
|
||||
row_offset: record.row_offset.map(i64::from),
|
||||
column: record.column,
|
||||
function: record.function,
|
||||
function_version: record.function_version,
|
||||
table_version: record.table_version as i64,
|
||||
error_type: record.error_type,
|
||||
error_message: record.error_message,
|
||||
created_at_millis: record.created_at_millis,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fragment whose per-row error detail was capped: `rowsSkipped` rows
|
||||
/// failed, of which only `rowsRecorded` have a record of their own.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FunctionErrorFragment {
|
||||
pub job_id: String,
|
||||
pub fragment_id: i64,
|
||||
pub rows_skipped: i64,
|
||||
pub rows_recorded: i64,
|
||||
}
|
||||
|
||||
impl From<lancedb::function::FunctionErrorFragment> for FunctionErrorFragment {
|
||||
fn from(fragment: lancedb::function::FunctionErrorFragment) -> Self {
|
||||
Self {
|
||||
job_id: fragment.job_id,
|
||||
fragment_id: fragment.fragment_id as i64,
|
||||
rows_skipped: fragment.rows_skipped as i64,
|
||||
rows_recorded: fragment.rows_recorded as i64,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A table's per-row Function errors.
|
||||
#[napi(object)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct FunctionErrors {
|
||||
/// The recorded rows, newest job first.
|
||||
pub records: Vec<FunctionErrorRecord>,
|
||||
/// Fragments whose detail was capped.
|
||||
pub fragments: Vec<FunctionErrorFragment>,
|
||||
/// Whether the listing stopped at its limit.
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
impl From<lancedb::function::FunctionErrors> for FunctionErrors {
|
||||
fn from(errors: lancedb::function::FunctionErrors) -> Self {
|
||||
Self {
|
||||
records: errors.records.into_iter().map(Into::into).collect(),
|
||||
fragments: errors.fragments.into_iter().map(Into::into).collect(),
|
||||
truncated: errors.truncated,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[napi(object)]
|
||||
pub struct RefreshMaterializedViewResult {
|
||||
/// How the view was brought up to date: "rebuild", "incremental" or "no_op".
|
||||
|
||||
Reference in New Issue
Block a user