mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-04 01:36:52 +00:00
A 1M-row column refresh over 200 fragments produced no visible result,
and the client could only ever say `"running"`. Everything needed to
diagnose it already existed server-side — the job registry records a
`claim`/`claim_complete` pair per fragment carrying `rows_processed` —
but none of it was reachable.
## Before
Four ways to ask about a job, none of which told you much.
```python
job = table.refresh_column_async("embedding")
job.status() # "running". That was the entire debug surface.
db.get_job(job_id) # state, and a spec. No result, no progress.
db.job_history(job_id) # raw record batches, no limit, no filter
db.job(job_id) # a handle that knew nothing
```
## After
Open a job the way you open a table; the handle answers everything.
```python
job = db.open_job(job_id) # raises JobNotFoundError if there is no such job
```
```python
>>> print(job)
Job(
id='job-1',
state='failed',
job_type='refresh_column',
creation_ms=1757000000000,
spec={
"column": "embedding",
"num_workers": 4
},
failure=JobFailureInfo(phase='execute', message='worker died', retryable=True),
)
```
Individual fields are there too — `job.state`, `job.job_type`,
`job.creation_ms`, `job.spec`, `job.result`, `job.failure` — and
`job.result` carries `rows_assigned` / `rows_failed` as soon as the job
succeeds, with no `wait()` required.
Per-fragment progress *while it is still running*:
```python
done = job.events(filter="state = 'claim_complete'", limit=10_000)
done.column("rows_processed").to_pylist() # [5000, 5000, ...]
```
The handle an async action returns is the same object, one `refresh()`
away:
```python
job = table.refresh_column_async("embedding")
job.refresh()
job.state, job.result
```
TypeScript is the same experience, down to `console.log`:
```ts
const job = await db.openJob(jobId); // rejects if there is no such job
console.log(job); // same multi-line layout
job.state; job.jobType; job.spec; job.result; job.failure;
const done = await job.events({ filter: "state = 'claim_complete'", limit: 10_000 });
```
## Why each piece matters
- **A result without waiting.** `rows_assigned` / `rows_failed` used to
live only on the terminal result, so a job that never terminated
reported nothing at all.
- **`limit`.** The server caps event rows at 1000 and truncates without
saying so, which silently hid most of a 200-fragment job's history.
- **`filter`.** `claim_complete` rows carry per-claim `rows_processed` —
the only progress signal that exists mid-flight.
- **Events outlive the worker.** They live in the job registry, not in
pod logs that vanish with the pod.
- **One place to ask.** `open_job` replaces `describe_job`,
`query_job_events` and `job`, so a question about a job has one answer
instead of one per calling location.
- **A missing job is an error, not a `None`.** The common case is a job
id copied out of a log, where absence is the surprise worth raising —
and it matches `open_table`.
- **Printing is the debug surface.** Every field on its own line, JSON
payloads keeping their structure. An unrefreshed handle stays on one
line, because there is nothing to lay out.
- **In-process jobs say so.** A local refresh reports `state` and leaves
the rest null rather than inventing fields it has no record for.
`list_jobs` and `cancel_job` stay as they were: one lists, the other is
a one-shot action that should not need a describe first.
## Breaking
All shipped in 0.38.0. No deprecated aliases.
| Was | Now |
| --- | --- |
| `Connection.get_job` → `describe_job` | `Connection.open_job` returns
a populated `Job`, or raises |
| `Connection.job_history` → `query_job_events` | `job.events(...)` |
| `Connection.job` | `Connection.open_job` |
| Python events → `List[pa.RecordBatch]` | `pa.Table` |
| `JobDescription.spec_json` / `.result_json` | internal; use `job.spec`
/ `job.result` |
Node's `Job` is now a TypeScript class wrapping the native handle, so it
returns an Arrow table and parsed values like Python does. New
`Error::JobNotFound` / `JobNotFoundError`; the three job exceptions are
now in the Python API reference.
161 lines
6.2 KiB
Rust
161 lines
6.2 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
use pyo3::{
|
|
PyErr, PyResult, Python,
|
|
exceptions::{PyIOError, PyNotImplementedError, PyOSError, PyRuntimeError, PyValueError},
|
|
intern,
|
|
types::{PyAnyMethods, PyNone},
|
|
};
|
|
|
|
use lancedb::error::Error as LanceError;
|
|
|
|
pub trait PythonErrorExt<T> {
|
|
/// Convert to a python error based on the Lance error type
|
|
fn infer_error(self) -> PyResult<T>;
|
|
/// Convert to OSError
|
|
fn os_error(self) -> PyResult<T>;
|
|
/// Convert to RuntimeError
|
|
fn runtime_error(self) -> PyResult<T>;
|
|
/// Convert to ValueError
|
|
fn value_error(self) -> PyResult<T>;
|
|
}
|
|
|
|
impl<T> PythonErrorExt<T> for std::result::Result<T, LanceError> {
|
|
fn infer_error(self) -> PyResult<T> {
|
|
match &self {
|
|
Ok(_) => Ok(self.unwrap()),
|
|
Err(err) => match err {
|
|
LanceError::InvalidInput { .. }
|
|
| LanceError::InvalidTableName { .. }
|
|
| LanceError::TableNotFound { .. }
|
|
| LanceError::Schema { .. }
|
|
| LanceError::TableAlreadyExists { .. } => self.value_error(),
|
|
LanceError::CreateDir { .. } => self.os_error(),
|
|
LanceError::ObjectStore { .. } => Err(PyIOError::new_err(err.to_string())),
|
|
LanceError::NotSupported { .. } => {
|
|
Err(PyNotImplementedError::new_err(err.to_string()))
|
|
}
|
|
LanceError::Http {
|
|
request_id,
|
|
source,
|
|
status_code,
|
|
} => Python::attach(|py| {
|
|
let message = err.to_string();
|
|
let http_err_cls = py
|
|
.import(intern!(py, "lancedb.remote.errors"))?
|
|
.getattr(intern!(py, "HttpError"))?;
|
|
let err = http_err_cls.call1((
|
|
message,
|
|
request_id,
|
|
status_code.map(|s| s.as_u16()),
|
|
))?;
|
|
|
|
if let Some(cause) = source.source() {
|
|
// The HTTP error already includes the first cause. But
|
|
// we can add the rest of the chain if there is any more.
|
|
let cause_err = http_from_rust_error(
|
|
py,
|
|
cause,
|
|
request_id,
|
|
status_code.map(|s| s.as_u16()),
|
|
)?;
|
|
err.setattr(intern!(py, "__cause__"), cause_err)?;
|
|
}
|
|
|
|
Err(PyErr::from_value(err))
|
|
}),
|
|
LanceError::Retry {
|
|
request_id,
|
|
request_failures,
|
|
max_request_failures,
|
|
connect_failures,
|
|
max_connect_failures,
|
|
read_failures,
|
|
max_read_failures,
|
|
source,
|
|
status_code,
|
|
} => Python::attach(|py| {
|
|
let cause_err = http_from_rust_error(
|
|
py,
|
|
source.as_ref(),
|
|
request_id,
|
|
status_code.map(|s| s.as_u16()),
|
|
)?;
|
|
|
|
let message = err.to_string();
|
|
let retry_error_cls = py
|
|
.import(intern!(py, "lancedb.remote.errors"))?
|
|
.getattr("RetryError")?;
|
|
let err = retry_error_cls.call1((
|
|
message,
|
|
request_id,
|
|
*request_failures,
|
|
*connect_failures,
|
|
*read_failures,
|
|
*max_request_failures,
|
|
*max_connect_failures,
|
|
*max_read_failures,
|
|
status_code.map(|s| s.as_u16()),
|
|
))?;
|
|
|
|
err.setattr(intern!(py, "__cause__"), cause_err)?;
|
|
Err(PyErr::from_value(err))
|
|
}),
|
|
LanceError::JobFailed { .. } => Python::attach(|py| {
|
|
let cls = py
|
|
.import(intern!(py, "lancedb.exceptions"))?
|
|
.getattr(intern!(py, "JobFailedError"))?;
|
|
Err(PyErr::from_value(cls.call1((err.to_string(),))?))
|
|
}),
|
|
LanceError::JobCancelled { .. } => Python::attach(|py| {
|
|
let cls = py
|
|
.import(intern!(py, "lancedb.exceptions"))?
|
|
.getattr(intern!(py, "JobCancelledError"))?;
|
|
Err(PyErr::from_value(cls.call1((err.to_string(),))?))
|
|
}),
|
|
LanceError::JobNotFound { .. } => Python::attach(|py| {
|
|
let cls = py
|
|
.import(intern!(py, "lancedb.exceptions"))?
|
|
.getattr(intern!(py, "JobNotFoundError"))?;
|
|
Err(PyErr::from_value(cls.call1((err.to_string(),))?))
|
|
}),
|
|
_ => self.runtime_error(),
|
|
},
|
|
}
|
|
}
|
|
|
|
fn os_error(self) -> PyResult<T> {
|
|
self.map_err(|err| PyOSError::new_err(err.to_string()))
|
|
}
|
|
|
|
fn runtime_error(self) -> PyResult<T> {
|
|
self.map_err(|err| PyRuntimeError::new_err(err.to_string()))
|
|
}
|
|
|
|
fn value_error(self) -> PyResult<T> {
|
|
self.map_err(|err| PyValueError::new_err(err.to_string()))
|
|
}
|
|
}
|
|
|
|
fn http_from_rust_error(
|
|
py: Python<'_>,
|
|
err: &dyn std::error::Error,
|
|
request_id: &str,
|
|
status_code: Option<u16>,
|
|
) -> PyResult<PyErr> {
|
|
let message = err.to_string();
|
|
let http_err_cls = py.import("lancedb.remote.errors")?.getattr("HttpError")?;
|
|
let py_err = http_err_cls.call1((message, request_id, status_code))?;
|
|
|
|
// Reset the traceback since it doesn't provide additional information.
|
|
let py_err = py_err.call_method1(intern!(py, "with_traceback"), (PyNone::get(py),))?;
|
|
|
|
if let Some(cause) = err.source() {
|
|
let cause_err = http_from_rust_error(py, cause, request_id, status_code)?;
|
|
py_err.setattr(intern!(py, "__cause__"), cause_err)?;
|
|
}
|
|
|
|
Ok(PyErr::from_value(py_err))
|
|
}
|