From 5231d37f8d7af114a50ff55b057d4287e491756e Mon Sep 17 00:00:00 2001 From: Wyatt Alt Date: Thu, 17 Sep 2026 05:12:50 -0700 Subject: [PATCH] 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. --- Cargo.lock | 4 +- docs/src/js/classes/Table.md | 35 +++++ docs/src/js/globals.md | 4 + .../js/interfaces/FunctionErrorFragment.md | 42 ++++++ docs/src/js/interfaces/FunctionErrorRecord.md | 92 +++++++++++++ docs/src/js/interfaces/FunctionErrors.md | 39 ++++++ .../js/interfaces/FunctionErrorsOptions.md | 39 ++++++ docs/src/python/python.md | 6 + nodejs/__test__/remote.test.ts | 60 ++++++++ nodejs/__test__/table.test.ts | 8 ++ nodejs/lancedb/index.ts | 4 + nodejs/lancedb/table.ts | 31 +++++ nodejs/src/table.rs | 117 ++++++++++++++++ python/python/lancedb/__init__.py | 3 + python/python/lancedb/_lancedb.pyi | 29 ++++ python/python/lancedb/remote/table.py | 10 ++ python/python/lancedb/table.py | 97 +++++++++++++ python/python/tests/test_remote_db.py | 65 +++++++++ python/python/tests/test_table.py | 7 + python/src/lib.rs | 8 +- python/src/table.rs | 130 ++++++++++++++++++ rust/lancedb/src/function.rs | 95 +++++++++++++ rust/lancedb/src/remote/table.rs | 111 +++++++++++++++ rust/lancedb/src/table.rs | 67 +++++++++ 24 files changed, 1099 insertions(+), 4 deletions(-) create mode 100644 docs/src/js/interfaces/FunctionErrorFragment.md create mode 100644 docs/src/js/interfaces/FunctionErrorRecord.md create mode 100644 docs/src/js/interfaces/FunctionErrors.md create mode 100644 docs/src/js/interfaces/FunctionErrorsOptions.md diff --git a/Cargo.lock b/Cargo.lock index 0d6a28878..1d1861a59 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5368,8 +5368,8 @@ dependencies = [ [[package]] name = "lance-geo" -version = "13.0.0-beta.3" -source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.3#565808c992c3d2adf1909b9753b50a28b6bd8a19" +version = "13.0.0-beta.4" +source = "git+https://github.com/lance-format/lance.git?tag=v13.0.0-beta.4#fea665e0d96d73acd1be330ce850973ff00b7126" dependencies = [ "datafusion", "geo-traits", diff --git a/docs/src/js/classes/Table.md b/docs/src/js/classes/Table.md index dfa0a9819..a8c382c27 100644 --- a/docs/src/js/classes/Table.md +++ b/docs/src/js/classes/Table.md @@ -578,6 +578,41 @@ so this is safe to call repeatedly. *** +### functionErrors() + +```ts +abstract functionErrors(options?): Promise +``` + +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. + +#### Parameters + +* **options?**: [`FunctionErrorsOptions`](../interfaces/FunctionErrorsOptions.md) + Optional filters: `jobId`, + `column`, and `limit` (server default 10000, cap 100000). + +#### Returns + +`Promise`<[`FunctionErrors`](../interfaces/FunctionErrors.md)> + +The records, the capped fragments, +and whether the listing stopped at its limit. + +#### Example + +```ts +const { records, truncated } = await table.functionErrors({ column: "embedding" }); +``` + +*** + ### getLsmStats() ```ts diff --git a/docs/src/js/globals.md b/docs/src/js/globals.md index 92e7940f0..6d242a15b 100644 --- a/docs/src/js/globals.md +++ b/docs/src/js/globals.md @@ -92,6 +92,10 @@ - [FtsToken](interfaces/FtsToken.md) - [FullTextQuery](interfaces/FullTextQuery.md) - [FullTextSearchOptions](interfaces/FullTextSearchOptions.md) +- [FunctionErrorFragment](interfaces/FunctionErrorFragment.md) +- [FunctionErrorRecord](interfaces/FunctionErrorRecord.md) +- [FunctionErrors](interfaces/FunctionErrors.md) +- [FunctionErrorsOptions](interfaces/FunctionErrorsOptions.md) - [GenerationStats](interfaces/GenerationStats.md) - [HnswPqOptions](interfaces/HnswPqOptions.md) - [HnswSqOptions](interfaces/HnswSqOptions.md) diff --git a/docs/src/js/interfaces/FunctionErrorFragment.md b/docs/src/js/interfaces/FunctionErrorFragment.md new file mode 100644 index 000000000..3a65d5486 --- /dev/null +++ b/docs/src/js/interfaces/FunctionErrorFragment.md @@ -0,0 +1,42 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / FunctionErrorFragment + +# Interface: FunctionErrorFragment + +A fragment whose per-row error detail was capped: `rowsSkipped` rows +failed, of which only `rowsRecorded` have a record of their own. + +## Properties + +### fragmentId + +```ts +fragmentId: number; +``` + +*** + +### jobId + +```ts +jobId: string; +``` + +*** + +### rowsRecorded + +```ts +rowsRecorded: number; +``` + +*** + +### rowsSkipped + +```ts +rowsSkipped: number; +``` diff --git a/docs/src/js/interfaces/FunctionErrorRecord.md b/docs/src/js/interfaces/FunctionErrorRecord.md new file mode 100644 index 000000000..3a26534a0 --- /dev/null +++ b/docs/src/js/interfaces/FunctionErrorRecord.md @@ -0,0 +1,92 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / FunctionErrorRecord + +# Interface: FunctionErrorRecord + +One row a Function refresh skipped, as the server recorded it. + +## Properties + +### column + +```ts +column: string; +``` + +*** + +### createdAtMillis + +```ts +createdAtMillis: number; +``` + +*** + +### errorMessage + +```ts +errorMessage: string; +``` + +*** + +### errorType + +```ts +errorType: string; +``` + +*** + +### fragmentId + +```ts +fragmentId: number; +``` + +*** + +### function + +```ts +function: string; +``` + +*** + +### functionVersion + +```ts +functionVersion: string; +``` + +*** + +### jobId + +```ts +jobId: string; +``` + +*** + +### rowOffset? + +```ts +optional rowOffset: number; +``` + +The row's offset within the fragment; absent when the fragment's +detail was capped. + +*** + +### tableVersion + +```ts +tableVersion: number; +``` diff --git a/docs/src/js/interfaces/FunctionErrors.md b/docs/src/js/interfaces/FunctionErrors.md new file mode 100644 index 000000000..bee0a80d8 --- /dev/null +++ b/docs/src/js/interfaces/FunctionErrors.md @@ -0,0 +1,39 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / FunctionErrors + +# Interface: FunctionErrors + +A table's per-row Function errors. + +## Properties + +### fragments + +```ts +fragments: FunctionErrorFragment[]; +``` + +Fragments whose detail was capped. + +*** + +### records + +```ts +records: FunctionErrorRecord[]; +``` + +The recorded rows, newest job first. + +*** + +### truncated + +```ts +truncated: boolean; +``` + +Whether the listing stopped at its limit. diff --git a/docs/src/js/interfaces/FunctionErrorsOptions.md b/docs/src/js/interfaces/FunctionErrorsOptions.md new file mode 100644 index 000000000..af0e71cfe --- /dev/null +++ b/docs/src/js/interfaces/FunctionErrorsOptions.md @@ -0,0 +1,39 @@ +[**@lancedb/lancedb**](../README.md) • **Docs** + +*** + +[@lancedb/lancedb](../globals.md) / FunctionErrorsOptions + +# Interface: FunctionErrorsOptions + +Which per-row Function errors to list; every filter is optional. + +## Properties + +### column? + +```ts +optional column: string; +``` + +Only errors on this column. + +*** + +### jobId? + +```ts +optional jobId: string; +``` + +Only errors recorded by this job. + +*** + +### limit? + +```ts +optional limit: number; +``` + +At most this many records (server default 10000, cap 100000). diff --git a/docs/src/python/python.md b/docs/src/python/python.md index 80899b964..d1ba66280 100644 --- a/docs/src/python/python.md +++ b/docs/src/python/python.md @@ -170,6 +170,12 @@ listing a storage directory. ::: lancedb.functions.RefreshColumnResult +::: lancedb.FunctionErrors + +::: lancedb.FunctionErrorRecord + +::: lancedb.FunctionErrorFragment + ::: lancedb.job.Job ::: lancedb.job.AsyncJob diff --git a/nodejs/__test__/remote.test.ts b/nodejs/__test__/remote.test.ts index 85d725825..40f4c67db 100644 --- a/nodejs/__test__/remote.test.ts +++ b/nodejs/__test__/remote.test.ts @@ -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) => { diff --git a/nodejs/__test__/table.test.ts b/nodejs/__test__/table.test.ts index 852bdec80..4142af696 100644 --- a/nodejs/__test__/table.test.ts +++ b/nodejs/__test__/table.test.ts @@ -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 }]); diff --git a/nodejs/lancedb/index.ts b/nodejs/lancedb/index.ts index 39ceec917..c0a4dfcbd 100644 --- a/nodejs/lancedb/index.ts +++ b/nodejs/lancedb/index.ts @@ -56,6 +56,10 @@ export { AddResult, AddColumnsResult, RefreshColumnResult, + FunctionErrors, + FunctionErrorsOptions, + FunctionErrorRecord, + FunctionErrorFragment, RefreshMaterializedViewResult, AlterColumnsResult, UpdateFieldMetadataResult, diff --git a/nodejs/lancedb/table.ts b/nodejs/lancedb/table.ts index 0280ac7f8..a098322a9 100644 --- a/nodejs/lancedb/table.ts +++ b/nodejs/lancedb/table.ts @@ -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; + /** + * 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} 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; + /** * 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 { + validateNonNegativeInteger(options?.limit, "limit"); + return await this.inner.functionErrors(options); + } + async refreshMaterializedView( full?: boolean, sourceVersion?: number, diff --git a/nodejs/src/table.rs b/nodejs/src/table.rs index 344017d08..f90e1fc91 100644 --- a/nodejs/src/table.rs +++ b/nodejs/src/table.rs @@ -427,6 +427,32 @@ impl Table { Ok(crate::job::Job::new(job)) } + #[napi(catch_unwind)] + pub async fn function_errors( + &self, + options: Option, + ) -> napi::Result { + 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, + /// Only errors on this column. + pub column: Option, + /// At most this many records (server default 10000, cap 100000). + pub limit: Option, +} + +/// 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, + 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 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 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, + /// Fragments whose detail was capped. + pub fragments: Vec, + /// Whether the listing stopped at its limit. + pub truncated: bool, +} + +impl From 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". diff --git a/python/python/lancedb/__init__.py b/python/python/lancedb/__init__.py index 05ad664e1..362e0cdbf 100644 --- a/python/python/lancedb/__init__.py +++ b/python/python/lancedb/__init__.py @@ -12,6 +12,9 @@ __version__ = importlib.metadata.version("lancedb") from ._lancedb import connect as lancedb_connect from ._lancedb import FtsToken +from ._lancedb import FunctionErrorFragment as FunctionErrorFragment +from ._lancedb import FunctionErrorRecord as FunctionErrorRecord +from ._lancedb import FunctionErrors as FunctionErrors from ._lancedb import LsmWriteSpec from ._lancedb import tokenize as _tokenize from .common import URI, sanitize_uri diff --git a/python/python/lancedb/_lancedb.pyi b/python/python/lancedb/_lancedb.pyi index a0039df05..7c6611608 100644 --- a/python/python/lancedb/_lancedb.pyi +++ b/python/python/lancedb/_lancedb.pyi @@ -461,6 +461,12 @@ class Table: ) -> AddColumnsResult: ... async def refresh_column(self, column: str) -> RefreshColumnResult: ... async def refresh_column_async(self, column: str) -> Job: ... + async def function_errors( + self, + job_id: Optional[str] = None, + column: Optional[str] = None, + limit: Optional[int] = None, + ) -> FunctionErrors: ... async def refresh_materialized_view( self, full: bool = False, source_version: Optional[int] = None ) -> RefreshMaterializedViewResult: ... @@ -820,6 +826,29 @@ class RefreshColumnResult: rows_filled: int version: int +class FunctionErrorRecord: + job_id: str + fragment_id: int + row_offset: Optional[int] + column: str + function: str + function_version: str + table_version: int + error_type: str + error_message: str + created_at_millis: int + +class FunctionErrorFragment: + job_id: str + fragment_id: int + rows_skipped: int + rows_recorded: int + +class FunctionErrors: + records: list[FunctionErrorRecord] + fragments: list[FunctionErrorFragment] + truncated: bool + class RefreshMaterializedViewResult: @staticmethod def from_json(value: str) -> RefreshMaterializedViewResult: ... diff --git a/python/python/lancedb/remote/table.py b/python/python/lancedb/remote/table.py index 89b958165..a9fc06bdd 100644 --- a/python/python/lancedb/remote/table.py +++ b/python/python/lancedb/remote/table.py @@ -994,6 +994,16 @@ class RemoteTable(Table): def refresh_column_async(self, column: str) -> Job[RefreshColumnResult]: return Job(LOOP.run(self._table.refresh_column_async(column))) + def function_errors( + self, + job_id: Optional[str] = None, + column: Optional[str] = None, + limit: Optional[int] = None, + ): + return LOOP.run( + self._table.function_errors(job_id=job_id, column=column, limit=limit) + ) + def alter_columns( self, *alterations: Iterable[Dict[str, str]] ) -> AlterColumnsResult: diff --git a/python/python/lancedb/table.py b/python/python/lancedb/table.py index 95b10422a..d9c27ba81 100644 --- a/python/python/lancedb/table.py +++ b/python/python/lancedb/table.py @@ -186,6 +186,7 @@ if TYPE_CHECKING: CompactionStats, Tag, AddColumnsResult, + FunctionErrors, RefreshColumnResult, AddResult, AlterColumnsResult, @@ -2308,6 +2309,47 @@ class Table(ABC): 'finished' """ + @abstractmethod + def function_errors( + self, + job_id: Optional[str] = None, + column: Optional[str] = None, + limit: Optional[int] = None, + ) -> "FunctionErrors": + """ + 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. + + Parameters + ---------- + job_id: str, optional + Only errors recorded by this job. + column: str, optional + Only errors on this column. + limit: int, optional + At most this many records (server default 10000, cap 100000). + + Returns + ------- + FunctionErrors + ``records``, ``fragments`` and ``truncated``, the last saying + whether the listing stopped at its limit. + + Examples + -------- + >>> errors = table.function_errors(column="embedding") # doctest: +SKIP + >>> for record in errors.records: # doctest: +SKIP + ... print(record.job_id, record.row_offset, record.error_message) + >>> if errors.truncated: # doctest: +SKIP + ... print("listing stopped at the limit") + """ + @abstractmethod def alter_columns(self, *alterations: Iterable[Dict[str, str]]): """ @@ -4362,6 +4404,18 @@ class LanceTable(Table): """ return Job(LOOP.run(self._table.refresh_column_async(column))) + def function_errors( + self, + job_id: Optional[str] = None, + column: Optional[str] = None, + limit: Optional[int] = None, + ) -> "FunctionErrors": + """The per-row errors Function refreshes recorded on this table. See + [`Table.function_errors`][lancedb.table.Table.function_errors].""" + return LOOP.run( + self._table.function_errors(job_id=job_id, column=column, limit=limit) + ) + def alter_columns( self, *alterations: Iterable[Dict[str, str]] ) -> AlterColumnsResult: @@ -6428,6 +6482,49 @@ class AsyncTable: """ return await self._inner.refresh_column(column) + async def function_errors( + self, + job_id: Optional[str] = None, + column: Optional[str] = None, + limit: Optional[int] = None, + ) -> "FunctionErrors": + """ + 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. + + Parameters + ---------- + job_id: str, optional + Only errors recorded by this job. + column: str, optional + Only errors on this column. + limit: int, optional + At most this many records (server default 10000, cap 100000). + + Returns + ------- + FunctionErrors + ``records``, ``fragments`` and ``truncated``, the last saying + whether the listing stopped at its limit. + + Examples + -------- + >>> errors = await table.function_errors(column="embedding") # doctest: +SKIP + >>> for record in errors.records: # doctest: +SKIP + ... print(record.job_id, record.row_offset, record.error_message) + >>> if errors.truncated: # doctest: +SKIP + ... print("listing stopped at the limit") + """ + return await self._inner.function_errors( + job_id=job_id, column=column, limit=limit + ) + async def refresh_column_async( self, column: str ) -> AsyncJob[RefreshColumnJobResult]: diff --git a/python/python/tests/test_remote_db.py b/python/python/tests/test_remote_db.py index 1e5a71e9a..1c887b218 100644 --- a/python/python/tests/test_remote_db.py +++ b/python/python/tests/test_remote_db.py @@ -982,6 +982,71 @@ def test_remote_refresh_async_returns_typed_terminal_result(): assert result.version == 8 +def test_remote_function_errors_lists_the_rows_a_refresh_skipped(): + listing = { + "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, + } + bodies = [] + + def handler(request): + content_len = int(request.headers.get("Content-Length", 0)) + body = request.rfile.read(content_len) if content_len > 0 else b"" + if request.path == "/v1/table/test/errors": + bodies.append(json.loads(body)) + request.send_response(200) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write(json.dumps(listing).encode()) + elif request.path == "/v1/table/test/describe/": + request.send_response(200) + request.send_header("Content-Type", "application/json") + request.end_headers() + request.wfile.write( + json.dumps({"version": 1, "schema": {"fields": []}}).encode() + ) + else: + request.send_response(404) + request.end_headers() + + with mock_lancedb_connection(handler) as db: + table = db.open_table("test") + errors = table.function_errors(job_id="j-7", column="embedding", limit=2) + everything = table.function_errors() + + assert bodies == [{"job_id": "j-7", "column": "embedding", "limit": 2}, {}] + assert errors.truncated is True + assert [r.error_message for r in errors.records] == ["bad input 'x'"] + assert errors.records[0].row_offset == 9 + assert errors.records[0].function_version == "2" + assert (errors.fragments[0].rows_skipped, errors.fragments[0].rows_recorded) == ( + 500, + 100, + ) + assert everything.truncated is True + + def test_remote_job_wait_raises_on_failure(): from lancedb.exceptions import JobFailedError from lancedb.index import BTree diff --git a/python/python/tests/test_table.py b/python/python/tests/test_table.py index a3cbf68f1..0b3dc01c3 100644 --- a/python/python/tests/test_table.py +++ b/python/python/tests/test_table.py @@ -4327,6 +4327,13 @@ async def test_computed_column_async(tmp_path): assert (await table.to_arrow())["tripled"].to_pylist() == [9] +def test_function_errors_are_remote_only(tmp_path): + db = lancedb.connect(tmp_path) + table = db.create_table("t", [{"x": 1}]) + with pytest.raises(NotImplementedError, match="LanceDB Cloud and Enterprise"): + table.function_errors() + + def test_refresh_column_async_returns_job(tmp_path): db = lancedb.connect(tmp_path) table = db.create_table("computed_job", [{"x": 1}, {"x": 2}]) diff --git a/python/src/lib.rs b/python/src/lib.rs index fd9a23798..ab04663c0 100644 --- a/python/src/lib.rs +++ b/python/src/lib.rs @@ -16,8 +16,9 @@ use query::{FTSQuery, HybridQuery, Query, VectorQuery}; use session::Session; use table::{ AddColumnsResult, AddResult, AlterColumnsResult, DeleteResult, DropColumnsResult, FtsToken, - LsmWriteSpec, MergeResult, PyBlobFile, RefreshColumnResult, RefreshMaterializedViewResult, - Table, UpdateFieldMetadataResult, UpdateResult, + FunctionErrorFragment, FunctionErrorRecord, FunctionErrors, LsmWriteSpec, MergeResult, + PyBlobFile, RefreshColumnResult, RefreshMaterializedViewResult, Table, + UpdateFieldMetadataResult, UpdateResult, }; pub mod arrow; @@ -84,6 +85,9 @@ pub fn _lancedb(py: Python, m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; m.add_class::()?; m.add_class::()?; m.add_class::()?; diff --git a/python/src/table.rs b/python/src/table.rs index 4cea61543..72e0095c6 100644 --- a/python/src/table.rs +++ b/python/src/table.rs @@ -441,6 +441,117 @@ impl From for RefreshColumnResult { } } +/// One row a Function refresh skipped, as the server recorded it. +#[pyclass(module = "lancedb._lancedb", get_all, from_py_object)] +#[derive(Clone, Debug)] +pub struct FunctionErrorRecord { + pub job_id: String, + pub fragment_id: u64, + pub row_offset: Option, + pub column: String, + pub function: String, + pub function_version: String, + pub table_version: u64, + pub error_type: String, + pub error_message: String, + pub created_at_millis: i64, +} + +#[pymethods] +impl FunctionErrorRecord { + pub fn __repr__(&self) -> String { + format!( + "FunctionErrorRecord(job_id={:?}, fragment_id={}, row_offset={:?}, column={:?}, \ + error_type={:?}, error_message={:?})", + self.job_id, + self.fragment_id, + self.row_offset, + self.column, + self.error_type, + self.error_message + ) + } +} + +impl From for FunctionErrorRecord { + fn from(record: lancedb::function::FunctionErrorRecord) -> Self { + Self { + job_id: record.job_id, + fragment_id: record.fragment_id, + row_offset: record.row_offset, + column: record.column, + function: record.function, + function_version: record.function_version, + table_version: record.table_version, + 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. +#[pyclass(module = "lancedb._lancedb", get_all, from_py_object)] +#[derive(Clone, Debug)] +pub struct FunctionErrorFragment { + pub job_id: String, + pub fragment_id: u64, + pub rows_skipped: u64, + pub rows_recorded: u64, +} + +#[pymethods] +impl FunctionErrorFragment { + pub fn __repr__(&self) -> String { + format!( + "FunctionErrorFragment(job_id={:?}, fragment_id={}, rows_skipped={}, rows_recorded={})", + self.job_id, self.fragment_id, self.rows_skipped, self.rows_recorded + ) + } +} + +impl From for FunctionErrorFragment { + fn from(fragment: lancedb::function::FunctionErrorFragment) -> Self { + Self { + job_id: fragment.job_id, + fragment_id: fragment.fragment_id, + rows_skipped: fragment.rows_skipped, + rows_recorded: fragment.rows_recorded, + } + } +} + +/// A table's per-row Function errors. +#[pyclass(module = "lancedb._lancedb", get_all, from_py_object)] +#[derive(Clone, Debug)] +pub struct FunctionErrors { + pub records: Vec, + pub fragments: Vec, + pub truncated: bool, +} + +#[pymethods] +impl FunctionErrors { + pub fn __repr__(&self) -> String { + format!( + "FunctionErrors(records={}, fragments={}, truncated={})", + self.records.len(), + self.fragments.len(), + self.truncated + ) + } +} + +impl From 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, + } + } +} + #[pyclass(get_all, from_py_object)] #[derive(Clone, Debug)] pub struct RefreshMaterializedViewResult { @@ -1638,6 +1749,25 @@ impl Table { }) } + #[pyo3(signature = (job_id=None, column=None, limit=None))] + pub fn function_errors( + self_: PyRef<'_, Self>, + job_id: Option, + column: Option, + limit: Option, + ) -> PyResult> { + let inner = self_.inner_ref()?.clone(); + let request = lancedb::function::FunctionErrorsRequest { + job_id, + column, + limit, + }; + future_into_py(self_.py(), async move { + let errors = inner.function_errors(request).await.infer_error()?; + Ok(FunctionErrors::from(errors)) + }) + } + #[pyo3(signature = (full=false, source_version=None))] pub fn refresh_materialized_view( self_: PyRef<'_, Self>, diff --git a/rust/lancedb/src/function.rs b/rust/lancedb/src/function.rs index 0d593585a..3f790b6ab 100644 --- a/rust/lancedb/src/function.rs +++ b/rust/lancedb/src/function.rs @@ -731,6 +731,101 @@ pub struct RefreshColumnResult { pub published_version: Option, } +/// Which per-row errors [`crate::Table::function_errors`] lists. Every +/// filter is optional; the listing is table-addressed, so with none set it +/// covers every refresh of every column. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct FunctionErrorsRequest { + /// Only errors recorded by this job. + pub job_id: Option, + /// Only errors on this column. + pub column: Option, + /// At most this many records; the server default is 10000 and its cap + /// 100000. [`FunctionErrors::truncated`] says whether the cap was hit. + pub limit: Option, +} + +impl FunctionErrorsRequest { + /// A request with no filter. + pub fn new() -> Self { + Self::default() + } + + /// Only errors recorded by `job_id`. + pub fn job_id(mut self, job_id: impl Into) -> Self { + self.job_id = Some(job_id.into()); + self + } + + /// Only errors on `column`. + pub fn column(mut self, column: impl Into) -> Self { + self.column = Some(column.into()); + self + } + + /// At most `limit` records. + pub fn limit(mut self, limit: usize) -> Self { + self.limit = Some(limit); + self + } +} + +/// One row a Function refresh skipped, as the server recorded it. The +/// message carries the input that failed, which is why reading errors needs +/// read access to the table. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionErrorRecord { + /// The refresh job that recorded the error. + pub job_id: String, + /// The fragment holding the row. + pub fragment_id: u64, + /// The row's offset within the fragment; `None` when the fragment's + /// detail was capped and only the fragment summary remains. + #[serde(default)] + pub row_offset: Option, + /// The column being computed. + pub column: String, + /// The Function that failed. + pub function: String, + /// The Function's version. + pub function_version: String, + /// The table version the refresh read. + pub table_version: u64, + /// The error's class, as the executor reported it. + pub error_type: String, + /// The error's text. + pub error_message: String, + /// When the error was recorded, in milliseconds since the epoch. + pub created_at_millis: i64, +} + +/// A fragment whose per-row detail was capped: `rows_skipped` rows failed, +/// of which only `rows_recorded` have a [`FunctionErrorRecord`]. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionErrorFragment { + /// The refresh job that recorded the errors. + pub job_id: String, + /// The fragment. + pub fragment_id: u64, + /// Rows the refresh skipped in this fragment. + pub rows_skipped: u64, + /// Rows with a record of their own. + pub rows_recorded: u64, +} + +/// A table's per-row Function errors; see [`crate::Table::function_errors`]. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct FunctionErrors { + /// The recorded rows, newest job first. + pub records: Vec, + /// Fragments whose detail was capped. + #[serde(default)] + pub fragments: Vec, + /// Whether the listing stopped at its limit. + #[serde(default)] + pub truncated: bool, +} + impl RefreshColumnResult { /// Deprecated compatibility alias for `rows_assigned`. pub fn rows_filled(&self) -> u64 { diff --git a/rust/lancedb/src/remote/table.rs b/rust/lancedb/src/remote/table.rs index 75cf8cd00..4280c7437 100644 --- a/rust/lancedb/src/remote/table.rs +++ b/rust/lancedb/src/remote/table.rs @@ -3519,6 +3519,35 @@ impl BaseTable for RemoteTable { }))) } + async fn function_errors( + &self, + request: &crate::function::FunctionErrorsRequest, + ) -> Result { + let mut body = serde_json::json!({}); + if let Some(job_id) = &request.job_id { + body["job_id"] = serde_json::json!(job_id); + } + if let Some(column) = &request.column { + body["column"] = serde_json::json!(column); + } + if let Some(limit) = request.limit { + body["limit"] = serde_json::json!(limit); + } + self.apply_branch_body(&mut body); + let request = self + .client + .post(&format!("/v1/table/{}/errors", self.identifier)) + .json(&body); + let (request_id, response) = self.send(request, true).await?; + let response = self.check_table_response(&request_id, response).await?; + let body = response.text().await.err_to_http(request_id.clone())?; + serde_json::from_str(&body).map_err(|e| Error::Http { + source: format!("Failed to parse errors response: {}", e).into(), + request_id, + status_code: None, + }) + } + async fn alter_columns(&self, alterations: &[ColumnAlteration]) -> Result { self.check_mutable().await?; let body = alterations @@ -8188,6 +8217,88 @@ mod tests { ); } + /// The error listing is table-addressed with optional job and column + /// filters, mirroring the server's SQL surface, and the two non-record + /// signals come back as their own fields rather than as rows. + #[tokio::test] + async fn test_function_errors_lists_the_rows_a_refresh_skipped() { + use crate::function::{FunctionErrorFragment, FunctionErrorRecord, FunctionErrorsRequest}; + + let table = Table::new_with_handler("my_table", |request| { + assert_eq!(request.method(), "POST"); + assert_eq!(request.url().path(), "/v1/table/my_table/errors"); + let body = request.body().unwrap().as_bytes().unwrap(); + let value: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!( + value, + serde_json::json!({"job_id": "j-7", "column": "embedding", "limit": 2}) + ); + http::Response::builder() + .status(200) + .body( + r#"{"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}"#, + ) + .unwrap() + }); + + let errors = table + .function_errors( + FunctionErrorsRequest::new() + .job_id("j-7") + .column("embedding") + .limit(2), + ) + .await + .unwrap(); + assert_eq!( + errors.records, + [FunctionErrorRecord { + job_id: "j-7".into(), + fragment_id: 3, + row_offset: Some(9), + column: "embedding".into(), + function: "embed".into(), + function_version: "2".into(), + table_version: 11, + error_type: "ValueError".into(), + error_message: "bad input 'x'".into(), + created_at_millis: 1_700_000_000_000, + }] + ); + assert_eq!( + errors.fragments, + [FunctionErrorFragment { + job_id: "j-7".into(), + fragment_id: 4, + rows_skipped: 500, + rows_recorded: 100, + }] + ); + assert!(errors.truncated); + + // No filter sends no filter, and an empty listing reads as such. + let table = Table::new_with_handler("my_table", |request| { + let body = request.body().unwrap().as_bytes().unwrap(); + let value: serde_json::Value = serde_json::from_slice(body).unwrap(); + assert_eq!(value, serde_json::json!({})); + http::Response::builder() + .status(200) + .body(r#"{"records": []}"#) + .unwrap() + }); + let errors = table + .function_errors(FunctionErrorsRequest::new()) + .await + .unwrap(); + assert_eq!(errors, crate::function::FunctionErrors::default()); + } + /// The refresh handle is wrapped for read-freshness tracking, so it has to /// forward the detail APIs too -- this is the job an operator is holding /// when a backfill goes quiet. diff --git a/rust/lancedb/src/table.rs b/rust/lancedb/src/table.rs index 0cf6a0e4f..fbec73dfb 100644 --- a/rust/lancedb/src/table.rs +++ b/rust/lancedb/src/table.rs @@ -53,6 +53,7 @@ use crate::database::Database; use crate::database::read_freshness::TableFreshness; use crate::embeddings::{EmbeddingDefinition, EmbeddingRegistry, MemoryRegistry}; use crate::error::{Error, Result}; +use crate::function::FunctionErrorsRequest; use crate::index::IndexStatistics; use crate::index::{Index, IndexBuilder}; use crate::index::{IndexConfig, IndexStatisticsImpl, IndexType}; @@ -821,6 +822,17 @@ pub trait BaseTable: std::fmt::Display + std::fmt::Debug + Send + Sync { message: "computed columns are supported only on local tables".into(), }) } + /// The per-row errors Function refreshes recorded on this table; see + /// [`Table::function_errors`]. The default returns `NotSupported`. + async fn function_errors( + &self, + _request: &crate::function::FunctionErrorsRequest, + ) -> Result { + Err(Error::NotSupported { + message: "per-row Function errors are recorded only on LanceDB Cloud and Enterprise" + .into(), + }) + } /// Alter columns in the table. async fn alter_columns(&self, alterations: &[ColumnAlteration]) -> Result; /// Drop columns from the table. @@ -1850,6 +1862,36 @@ impl Table { self.inner.refresh_column_async(column.as_ref()).await } + /// The per-row errors Function refreshes recorded on this table: the + /// rows a refresh skipped under its skip policy, with the failing input + /// and the error, plus a summary for any fragment whose detail was + /// capped. Filter by job or column through the request; a listing that + /// hit its limit reports [`FunctionErrors::truncated`]. + /// + /// LanceDB Cloud and Enterprise only, and the caller needs read access + /// to the table, since a message carries the value that failed. + /// + /// ``` + /// # use lancedb::Table; + /// use lancedb::function::FunctionErrorsRequest; + /// + /// # async fn list_errors(table: &Table) -> Result<(), Box> { + /// let errors = table + /// .function_errors(FunctionErrorsRequest::new().column("embedding")) + /// .await?; + /// for record in &errors.records { + /// println!("{}: {}", record.error_type, record.error_message); + /// } + /// # Ok(()) + /// # } + /// ``` + pub async fn function_errors( + &self, + request: FunctionErrorsRequest, + ) -> Result { + self.inner.function_errors(&request).await + } + /// Change a column's name or nullability. pub async fn alter_columns( &self, @@ -4049,6 +4091,31 @@ mod tests { assert_eq!(table.name, "test") } + /// The per-row error store is a server feature; a local table says so + /// rather than answering with an empty listing. + #[tokio::test] + async fn test_function_errors_are_remote_only() { + let tmp_dir = tempdir().unwrap(); + let conn = connect(tmp_dir.path().to_str().unwrap()) + .execute() + .await + .unwrap(); + let batch = make_test_batches(); + let table = conn + .create_table("t", batch.clone()) + .execute() + .await + .unwrap(); + let err = table + .function_errors(FunctionErrorsRequest::new()) + .await + .unwrap_err(); + assert!( + matches!(&err, Error::NotSupported { message } if message.contains("Cloud and Enterprise")), + "{err:?}" + ); + } + #[tokio::test] async fn test_open_not_found() { let tmp_dir = tempdir().unwrap();