mirror of
https://github.com/lancedb/lancedb.git
synced 2026-09-11 07:42:26 +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.
279 lines
8.6 KiB
Rust
279 lines
8.6 KiB
Rust
// SPDX-License-Identifier: Apache-2.0
|
|
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
use std::sync::Arc;
|
|
|
|
use crate::runtime::future_into_py;
|
|
use arrow::{
|
|
datatypes::Schema,
|
|
pyarrow::{IntoPyArrow, Table as PyArrowTable},
|
|
};
|
|
use lancedb::job::JobEventsRequest;
|
|
use pyo3::{
|
|
Bound, PyAny, PyRef, PyResult, Python,
|
|
exceptions::PyValueError,
|
|
pyclass, pymethods,
|
|
types::{PyAnyMethods, PyDict, PyDictMethods},
|
|
};
|
|
use serde::Serialize;
|
|
|
|
use crate::error::PythonErrorExt;
|
|
|
|
const REPR_INDENT: &str = " ";
|
|
|
|
/// Parse a stored JSON payload into Python data. The bindings carry these as
|
|
/// strings because that is what crosses the boundary cheaply; the public
|
|
/// Python surface is the parsed form.
|
|
fn parse_json_payload<'py>(
|
|
py: Python<'py>,
|
|
raw: Option<&str>,
|
|
) -> PyResult<Option<Bound<'py, PyAny>>> {
|
|
match raw {
|
|
None => Ok(None),
|
|
Some(raw) => Ok(Some(py.import("json")?.call_method1("loads", (raw,))?)),
|
|
}
|
|
}
|
|
|
|
/// A payload rendered as indented JSON, aligned under the field that holds it.
|
|
fn pretty_json_payload(py: Python<'_>, raw: Option<&str>) -> PyResult<Option<String>> {
|
|
let Some(parsed) = parse_json_payload(py, raw)? else {
|
|
return Ok(None);
|
|
};
|
|
let kwargs = PyDict::new(py);
|
|
kwargs.set_item("indent", 4)?;
|
|
let rendered: String = py
|
|
.import("json")?
|
|
.call_method("dumps", (parsed,), Some(&kwargs))?
|
|
.extract()?;
|
|
Ok(Some(rendered.replace('\n', &format!("\n{REPR_INDENT}"))))
|
|
}
|
|
|
|
#[pyclass]
|
|
pub struct Job {
|
|
inner: Arc<lancedb::Job<std::result::Result<Option<String>, String>>>,
|
|
}
|
|
|
|
impl Job {
|
|
pub(crate) fn new(inner: lancedb::Job) -> Self {
|
|
Self {
|
|
inner: Arc::new(inner.map(|()| Ok(None))),
|
|
}
|
|
}
|
|
|
|
pub(crate) fn new_typed<T>(inner: lancedb::Job<T>) -> Self
|
|
where
|
|
T: Clone + Serialize + Send + Sync + 'static,
|
|
{
|
|
Self {
|
|
inner: Arc::new(inner.map(|result| {
|
|
serde_json::to_string(&result)
|
|
.map(Some)
|
|
.map_err(|error| format!("failed to serialize typed job result: {error}"))
|
|
})),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[pymethods]
|
|
impl Job {
|
|
#[getter]
|
|
pub fn id(&self) -> Option<String> {
|
|
self.inner.id().map(str::to_string)
|
|
}
|
|
|
|
pub fn status(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
|
let inner = self_.inner.clone();
|
|
future_into_py(
|
|
self_.py(),
|
|
async move { inner.status().await.infer_error() },
|
|
)
|
|
}
|
|
|
|
pub fn wait(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
|
let inner = self_.inner.clone();
|
|
future_into_py(self_.py(), async move {
|
|
let result = inner.wait().await.infer_error()?;
|
|
result
|
|
.map_err(|message| lancedb::Error::Runtime { message })
|
|
.infer_error()
|
|
})
|
|
}
|
|
|
|
pub fn cancel(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
|
let inner = self_.inner.clone();
|
|
future_into_py(self_.py(), async move {
|
|
inner.cancel().await.infer_error()?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
pub fn refresh(self_: PyRef<'_, Self>) -> PyResult<Bound<'_, PyAny>> {
|
|
let inner = self_.inner.clone();
|
|
future_into_py(self_.py(), async move {
|
|
inner.refresh().await.infer_error()?;
|
|
Ok(())
|
|
})
|
|
}
|
|
|
|
/// The last observed lifecycle state, without contacting the backend.
|
|
#[getter]
|
|
pub fn _state(&self) -> Option<String> {
|
|
self.inner.state()
|
|
}
|
|
|
|
/// The last observed server-side record. `None` for an in-process job.
|
|
#[getter]
|
|
pub fn _description(&self) -> Option<JobDescription> {
|
|
self.inner.description().map(JobDescription::from)
|
|
}
|
|
|
|
#[pyo3(signature = (*, limit=None, filter=None))]
|
|
pub fn events(
|
|
self_: PyRef<'_, Self>,
|
|
limit: Option<u32>,
|
|
filter: Option<String>,
|
|
) -> PyResult<Bound<'_, PyAny>> {
|
|
let inner = self_.inner.clone();
|
|
let request = JobEventsRequest { limit, filter };
|
|
future_into_py(self_.py(), async move {
|
|
let batches = inner.events(request).await.infer_error()?;
|
|
Python::attach(|py| {
|
|
let schema = batches
|
|
.first()
|
|
.map(|batch| batch.schema())
|
|
.unwrap_or_else(|| Arc::new(Schema::empty()));
|
|
let table = PyArrowTable::try_new(batches, schema)
|
|
.map_err(|err| PyValueError::new_err(err.to_string()))?;
|
|
table.into_pyarrow(py).map(|table| table.unbind())
|
|
})
|
|
})
|
|
}
|
|
}
|
|
|
|
/// A row from `Connection.list_jobs`: one server-side job.
|
|
#[pyclass(get_all, skip_from_py_object)]
|
|
#[derive(Clone)]
|
|
pub struct JobInfo {
|
|
job_id: String,
|
|
table: String,
|
|
job_type: String,
|
|
state: String,
|
|
created_at_millis: i64,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl JobInfo {
|
|
fn __repr__(&self) -> String {
|
|
format!(
|
|
"JobInfo(job_id={:?}, table={:?}, job_type={:?}, state={:?}, created_at_millis={})",
|
|
self.job_id, self.table, self.job_type, self.state, self.created_at_millis
|
|
)
|
|
}
|
|
}
|
|
|
|
impl From<lancedb::database::JobInfo> for JobInfo {
|
|
fn from(info: lancedb::database::JobInfo) -> Self {
|
|
Self {
|
|
job_id: info.job_id,
|
|
table: info.table,
|
|
job_type: info.job_type,
|
|
state: info.state,
|
|
created_at_millis: info.created_at_millis,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// The server's account of why a job failed.
|
|
#[pyclass(get_all, skip_from_py_object)]
|
|
#[derive(Clone)]
|
|
pub struct JobFailureInfo {
|
|
phase: Option<String>,
|
|
message: Option<String>,
|
|
retryable: Option<bool>,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl JobFailureInfo {
|
|
fn __repr__(&self) -> String {
|
|
format!(
|
|
"JobFailureInfo(phase={:?}, message={:?}, retryable={:?})",
|
|
self.phase, self.message, self.retryable
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The server-side record behind a `Job` handle.
|
|
#[pyclass(get_all, skip_from_py_object)]
|
|
#[derive(Clone)]
|
|
pub struct JobDescription {
|
|
job_id: String,
|
|
job_type: String,
|
|
state: String,
|
|
creation_ms: i64,
|
|
/// Internal: the wire form behind the `spec` property.
|
|
_spec_json: Option<String>,
|
|
/// Internal: the wire form behind the `result` property.
|
|
_result_json: Option<String>,
|
|
failure: Option<JobFailureInfo>,
|
|
}
|
|
|
|
#[pymethods]
|
|
impl JobDescription {
|
|
/// The job-type-specific specification it was submitted with.
|
|
#[getter]
|
|
fn spec<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyAny>>> {
|
|
parse_json_payload(py, self._spec_json.as_deref())
|
|
}
|
|
|
|
/// The job-type-specific terminal result. `None` until the job succeeds.
|
|
#[getter]
|
|
fn result<'py>(&self, py: Python<'py>) -> PyResult<Option<Bound<'py, PyAny>>> {
|
|
parse_json_payload(py, self._result_json.as_deref())
|
|
}
|
|
|
|
fn __repr__(&self, py: Python<'_>) -> PyResult<String> {
|
|
let mut fields = vec![
|
|
format!("job_id={:?}", self.job_id),
|
|
format!("job_type={:?}", self.job_type),
|
|
format!("state={:?}", self.state),
|
|
format!("creation_ms={}", self.creation_ms),
|
|
];
|
|
// Lay the payloads out as indented JSON, the same way the `Job` repr
|
|
// does, so the two agree on how the same data looks.
|
|
for (name, payload) in [("spec", &self._spec_json), ("result", &self._result_json)] {
|
|
if let Some(rendered) = pretty_json_payload(py, payload.as_deref())? {
|
|
fields.push(format!("{name}={rendered}"));
|
|
}
|
|
}
|
|
if let Some(failure) = &self.failure {
|
|
fields.push(format!("failure={}", failure.__repr__()));
|
|
}
|
|
let body = fields
|
|
.iter()
|
|
.map(|field| format!("\n{REPR_INDENT}{field},"))
|
|
.collect::<String>();
|
|
Ok(format!("JobDescription({body}\n)"))
|
|
}
|
|
}
|
|
|
|
impl From<lancedb::database::JobDescription> for JobDescription {
|
|
fn from(description: lancedb::database::JobDescription) -> Self {
|
|
Self {
|
|
job_id: description.job_id,
|
|
job_type: description.job_type,
|
|
state: description.state,
|
|
creation_ms: description.creation_ms,
|
|
_spec_json: (!description.spec.is_null()).then(|| description.spec.to_string()),
|
|
_result_json: description
|
|
.result
|
|
.filter(|result| !result.is_null())
|
|
.map(|result| result.to_string()),
|
|
failure: description.failure.map(|failure| JobFailureInfo {
|
|
phase: failure.phase,
|
|
message: failure.message,
|
|
retryable: failure.retryable,
|
|
}),
|
|
}
|
|
}
|
|
}
|