feat!: replace get_job/job_history with describe_job/query_job_events (#4130)

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.
This commit is contained in:
Jack Ye
2026-09-05 16:24:23 -07:00
committed by GitHub
parent 8c9c5c5a5f
commit 21f11b4463
30 changed files with 1679 additions and 585 deletions
+117 -15
View File
@@ -2467,7 +2467,7 @@ def test_remote_blob_byte_apis_not_supported_on_old_server():
def test_remote_connection_jobs_surface():
from lancedb.exceptions import JobFailedError
from lancedb.exceptions import JobFailedError, JobNotFoundError
schema = pa.schema([("state", pa.string())])
batch = pa.record_batch([pa.array(["created", "done"])], schema=schema)
@@ -2475,6 +2475,7 @@ def test_remote_connection_jobs_surface():
with pa.ipc.new_stream(sink, schema) as writer:
writer.write_batch(batch)
events_body = sink.getvalue().to_pybytes()
query_events_payloads = []
def handler(request):
content_len = int(request.headers.get("Content-Length", 0))
@@ -2512,6 +2513,22 @@ def test_remote_connection_jobs_surface():
request.end_headers()
request.wfile.write(json.dumps(rsp).encode())
elif request.path == "/v1/jobs/describe":
if payload["job_id"] == "job-2":
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
dict(
job_id="job-2",
job_type="refresh_column",
job_state="DONE",
creation_ms=2000,
result=dict(rows_assigned=1000000, rows_failed=0),
)
).encode()
)
return
if payload["job_id"] != "job-1":
request.send_response(404)
request.end_headers()
@@ -2543,7 +2560,7 @@ def test_remote_connection_jobs_surface():
request.end_headers()
request.wfile.write(b'{"job_id": "job-1"}')
elif request.path == "/v1/jobs/query_events":
assert payload["job_id"] == "job-1"
query_events_payloads.append(payload)
request.send_response(200)
request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
request.end_headers()
@@ -2559,24 +2576,109 @@ def test_remote_connection_jobs_surface():
assert jobs[0].table == "t1"
assert jobs[1].state == "finished"
description = db.get_job("job-1")
assert description.job_type == "create_index"
assert description.state == "failed"
assert json.loads(description.spec_json) == {"column": "vec"}
assert description.failure.message == "worker died"
assert description.failure.retryable is True
assert db.get_job("missing") is None
assert db.cancel_job("job-1") is True
assert db.cancel_job("missing") is False
batches = db.job_history("job-1")
assert len(batches) == 1
assert batches[0].num_rows == 2
assert batches[0].column("state").to_pylist() == ["created", "done"]
# Opening a job hands back a populated handle; a missing one fails.
with pytest.raises(JobNotFoundError, match="missing"):
db.open_job("missing")
finished = db.open_job("job-2")
assert finished.state == "finished"
assert finished.result == {"rows_assigned": 1000000, "rows_failed": 0}
job = db.job("job-1")
job = db.open_job("job-1")
assert job.id == "job-1"
# Opening already populated the handle.
assert job.state == "failed"
assert job.spec == {"column": "vec"}
assert job.failure.message == "worker died"
assert job.status() == "failed"
with pytest.raises(JobFailedError, match="worker died"):
job.wait(timeout=timedelta(seconds=5))
def test_remote_job_handle_reports_its_own_detail():
schema = pa.schema([("state", pa.string())])
batch = pa.record_batch([pa.array(["claim_complete"])], schema=schema)
sink = pa.BufferOutputStream()
with pa.ipc.new_stream(sink, schema) as writer:
writer.write_batch(batch)
events_body = sink.getvalue().to_pybytes()
event_payloads = []
def handler(request):
content_len = int(request.headers.get("Content-Length", 0))
body = request.rfile.read(content_len) if content_len > 0 else b""
payload = json.loads(body) if body else {}
if request.path == "/v1/jobs/describe":
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(
json.dumps(
dict(
job_id="job-1",
job_type="refresh_column",
job_state="DONE",
creation_ms=2000,
spec=dict(column="vec"),
result=dict(rows_assigned=1000000),
)
).encode()
)
elif request.path == "/v1/jobs/query_events":
event_payloads.append(payload)
request.send_response(200)
request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
request.end_headers()
request.wfile.write(events_body)
else:
request.send_response(404)
request.end_headers()
with mock_lancedb_connection(handler) as db:
job = db.open_job("job-1")
# Opening populates the handle in the same round trip.
assert job.state == "finished"
job.refresh()
assert job.job_type == "refresh_column"
assert job.creation_ms == 2000
assert job.spec == {"column": "vec"}
assert job.result == {"rows_assigned": 1000000}
assert job.failure is None
# The JSON payloads stay reachable, but as internal APIs.
assert json.loads(job._spec_json) == {"column": "vec"}
assert json.loads(job._result_json) == {"rows_assigned": 1000000}
# print() shows everything the handle knows and nothing it does not.
# print() lays every known field out on its own line, with the JSON
# payloads indented rather than crammed onto one line.
assert repr(job) == "\n".join(
[
"Job(",
" id='job-1',",
" state='finished',",
" job_type='refresh_column',",
" creation_ms=2000,",
" spec={",
' "column": "vec"',
" },",
" result={",
' "rows_assigned": 1000000',
" },",
")",
]
)
# Nothing it does not know shows up.
assert "failure" not in repr(job)
events = job.events(filter="state = 'claim_complete'", limit=500)
assert isinstance(events, pa.Table)
assert events.column("state").to_pylist() == ["claim_complete"]
# The handle supplies job_id; the caller only narrows the query.
assert event_payloads[-1] == {
"job_id": "job-1",
"limit": 500,
"filter": "state = 'claim_complete'",
}