feat: persist automatic Function refresh resource budgets (#4953)

## Depends on merged #4951

Phase 1 merged as `4d22c0ce2b6825c880c818e737b079db494b4053`, so this PR
now targets `main`. It reuses the canonical
`FunctionWorkerResourceBudget` introduced there rather than copying the
type.

For Function `add_columns` declarations, Python and Rust now persist
`auto_refresh_resource_budget` in computed-column metadata and send it
as `auto_refresh_resource_budget` in the remote request. CPU
milliseconds and memory bytes are required positive integers. Omitting
`gpu_count` is accepted as zero and the canonical outgoing JSON still
contains `"gpu_count": 0`. Unknown fields, missing CPU or memory, wrong
types, zero CPU or memory, negative values, and overflow are rejected.

The same budget is applied to every public output binding of a
multi-output Function declaration. Existing metadata is retained, and
SQL-expression declarations or local execution reject the budget instead
of silently ignoring it. Node is unchanged because its current
`addColumns` surface only accepts SQL computed expressions and has no
Function declaration path.

This change does not implement fleet call concurrency limits or enforce
the budget in the runtime. The Enterprise server must support the nested
Function worker resource-budget contract before these values can act as
execution limits.

## Validation

- Python extension build passed.
- Remote Function declaration and local unsupported-path rejection each
passed.
- Canonical metadata round-trip passed, including omitted GPU decoding
to zero and explicit `gpu_count: 0` on encode.
- Function multi-output remote wire test passed with `--features
remote`.
- Repository-scoped Python type check passed with 0 errors.
- All runtime source blobs match the native-tested source. The
post-native deltas are one documentation example line and an existing
routing-test stub update for the new optional argument; the affected
parameterized test passed 3/3 against the existing extension build. No
private test runners are part of this PR.
This commit is contained in:
Xuanwo
2026-11-11 11:23:17 +08:00
committed by GitHub
parent c9d176ae7e
commit 8029428c71
13 changed files with 427 additions and 31 deletions
+2
View File
@@ -615,6 +615,7 @@ class Table:
columns: list[tuple[str, str]],
auto_refresh_enabled: Optional[bool] = None,
auto_refresh_interval_seconds: Optional[int] = None,
auto_refresh_resource_budget: Optional[dict[str, int]] = None,
) -> AddColumnsResult: ...
async def add_function_columns(
self,
@@ -622,6 +623,7 @@ class Table:
output_name: Optional[str],
auto_refresh_enabled: Optional[bool] = None,
auto_refresh_interval_seconds: Optional[int] = None,
auto_refresh_resource_budget: Optional[dict[str, int]] = None,
) -> AddColumnsResult: ...
async def refresh_column(
self,
+2
View File
@@ -1127,6 +1127,7 @@ class RemoteTable(Table):
computed: Dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
) -> AddColumnsResult:
return LOOP.run(
self._table.add_columns(
@@ -1134,6 +1135,7 @@ class RemoteTable(Table):
computed=computed,
auto_refresh_enabled=auto_refresh_enabled,
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
auto_refresh_resource_budget=auto_refresh_resource_budget,
)
)
+19
View File
@@ -3085,6 +3085,7 @@ class Table(ABC):
computed: Dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
):
"""
Add new columns with defined values.
@@ -3153,6 +3154,12 @@ class Table(ABC):
When omitted, the server determines when to refresh. Applies to every
computed output in this call.
Scheduling options require computed or Function columns.
auto_refresh_resource_budget: dict[str, int], optional
Aggregate CPU, memory, and GPU budget for automatic refreshes of a
registered Function declaration. ``cpu_millis`` and
``memory_bytes`` are required; ``gpu_count`` defaults to zero.
This option cannot be used with SQL-expression columns. LanceDB
Enterprise only.
Returns
-------
@@ -5468,6 +5475,7 @@ class LanceTable(Table):
computed: Dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
) -> AddColumnsResult:
return LOOP.run(
self._table.add_columns(
@@ -5475,6 +5483,7 @@ class LanceTable(Table):
computed=computed,
auto_refresh_enabled=auto_refresh_enabled,
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
auto_refresh_resource_budget=auto_refresh_resource_budget,
)
)
@@ -7758,6 +7767,7 @@ class AsyncTable:
computed: dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
auto_refresh_resource_budget: Optional[Dict[str, int]] = None,
) -> AddColumnsResult:
"""
Add new columns with defined values.
@@ -7824,6 +7834,12 @@ class AsyncTable:
not force recomputation. Applies to every declared computed output.
These options cannot be used with one-time transforms or ordinary
all-null columns.
auto_refresh_resource_budget: dict[str, int], optional
Aggregate CPU, memory, and GPU budget for automatic refreshes of
registered Function outputs. ``cpu_millis`` and ``memory_bytes``
are required; ``gpu_count`` defaults to zero. SQL-expression
columns do not use Function workers and reject this option.
LanceDB Enterprise only.
Examples
--------
@@ -7852,6 +7868,7 @@ class AsyncTable:
options_supplied = (
auto_refresh_enabled is not None
or auto_refresh_interval_seconds is not None
or auto_refresh_resource_budget is not None
)
function_application = None
function_output_name = None
@@ -7880,6 +7897,7 @@ class AsyncTable:
function_output_name,
auto_refresh_enabled,
auto_refresh_interval_seconds,
auto_refresh_resource_budget,
)
if isinstance(transforms, pa.Field):
@@ -7900,6 +7918,7 @@ class AsyncTable:
list(computed.items()),
auto_refresh_enabled,
auto_refresh_interval_seconds,
auto_refresh_resource_budget,
)
if options_supplied:
raise ValueError(
@@ -459,6 +459,7 @@ class _FunctionDeclarationInner:
output_name,
auto_refresh_enabled,
auto_refresh_interval_seconds,
auto_refresh_resource_budget,
):
self.calls.append(
(
@@ -466,6 +467,7 @@ class _FunctionDeclarationInner:
output_name,
auto_refresh_enabled,
auto_refresh_interval_seconds,
auto_refresh_resource_budget,
)
)
return "declared"
@@ -491,7 +493,7 @@ async def test_add_columns_routes_struct_as_one_and_multi_output_binding_atomica
)
assert result == "declared"
assert inner.calls[-1][1] == "features"
assert inner.calls[-1][2:] == (None, None)
assert inner.calls[-1][2:] == (None, None, None)
bare = application._copy(update={"columns": {}}).rename(
columns={"normalized_text": "search_text"}
@@ -502,7 +504,7 @@ async def test_add_columns_routes_struct_as_one_and_multi_output_binding_atomica
assert result == "declared"
assert inner.calls[-1][1] is None
assert inner.calls[-1][0]["columns"] == {"normalized_text": "search_text"}
assert inner.calls[-1][2:] == (False, 300)
assert inner.calls[-1][2:] == (False, 300, None)
@pytest.mark.asyncio
+87
View File
@@ -36,6 +36,7 @@ from lancedb.graph import (
PropertyGraph,
PropertyGraphDescription,
)
from lancedb.functions import FunctionApplication
from lancedb.query import (
AsyncQuery,
ColumnOrdering,
@@ -5831,6 +5832,92 @@ def test_remote_add_columns_reader_not_supported():
assert all("/add_columns/" not in path for path in requests)
@pytest.mark.asyncio
async def test_remote_add_function_columns_forwards_auto_refresh_resource_budget():
bodies = []
application = FunctionApplication.from_json(
json.dumps(
{
"function": {
"name": "embed",
"version": "1",
"object_id": "fixture",
"location": "memory:///fixture",
"manifest_digest": (
"sha256:7e22f815b6648e14f093a3979a8e5a2082"
"fa773ebe1ec84b135cae7e84d6f8e6"
),
},
"inputs": [
{
"parameter": "text",
"kind": "column",
"value": {"path": "text"},
}
],
"output": {
"kind": "scalar",
"arrow_type": "float32",
"nullable": False,
},
}
)
)
def handler(request):
if request.path == "/v1/table/test/describe/":
response = {
"version": 1,
"schema": {
"fields": [
{
"name": "text",
"type": {"type": "string"},
"nullable": True,
}
]
},
}
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(json.dumps(response).encode())
elif request.path == "/v1/table/test/add_columns/":
content_len = int(request.headers.get("Content-Length", 0))
bodies.append(json.loads(request.rfile.read(content_len)))
request.send_response(200)
request.send_header("Content-Type", "application/json")
request.end_headers()
request.wfile.write(b'{"version": 2}')
else:
request.send_response(404)
request.end_headers()
budget = {"cpu_millis": 2_000, "memory_bytes": 4 * 1024**3}
with mock_lancedb_connection(handler) as db:
table = db.open_table("test")
table.add_columns(
{"embedding": application},
auto_refresh_resource_budget=budget,
)
async with mock_lancedb_connection_async(handler) as db:
table = await db.open_table("test")
await table.add_columns(
{"embedding": application},
auto_refresh_resource_budget=budget,
)
assert len(bodies) == 2
for body in bodies:
assert body["auto_refresh_enabled"] is True
assert body["auto_refresh_resource_budget"] == {
**budget,
"gpu_count": 0,
}
assert body["new_columns"] == [{"name": "embedding", "all_null": True}]
@pytest.mark.asyncio
async def test_async_property_graph_handles():
calls = []
+15
View File
@@ -6255,6 +6255,21 @@ def test_auto_refresh_options_require_computed_columns(tmp_path):
table.add_columns({"doubled": "x * 2"}, auto_refresh_enabled=True)
def test_auto_refresh_resource_budget_requires_a_function(tmp_path):
table = lancedb.connect(tmp_path).create_table("invalid_budget", [{"x": 1}])
before = table.version
with pytest.raises(ValueError, match="registered Function"):
table.add_columns(
computed={"doubled": "x * 2"},
auto_refresh_resource_budget={
"cpu_millis": 1_000,
"memory_bytes": 1_024,
},
)
assert table.version == before
assert table.schema.names == ["x"]
@pytest.mark.experimental_functions
@pytest.mark.asyncio
async def test_async_computed_auto_refresh_uses_server_interval(tmp_path):
+16 -8
View File
@@ -2217,17 +2217,21 @@ impl Table {
})
}
#[pyo3(signature = (columns, auto_refresh_enabled=None, auto_refresh_interval_seconds=None))]
pub fn add_computed_columns(
self_: PyRef<'_, Self>,
#[pyo3(signature = (columns, auto_refresh_enabled=None, auto_refresh_interval_seconds=None, auto_refresh_resource_budget=None))]
pub fn add_computed_columns<'py>(
self_: PyRef<'py, Self>,
columns: Vec<(String, String)>,
auto_refresh_enabled: Option<bool>,
auto_refresh_interval_seconds: Option<u64>,
) -> PyResult<Bound<'_, PyAny>> {
auto_refresh_resource_budget: Option<Bound<'py, PyDict>>,
) -> PyResult<Bound<'py, PyAny>> {
let inner = self_.inner_ref()?.clone();
let options = ComputedColumnOptions {
auto_refresh_enabled: auto_refresh_enabled.unwrap_or(true),
auto_refresh_interval_seconds,
auto_refresh_resource_budget: parse_resource_budget(
auto_refresh_resource_budget.as_ref(),
)?,
};
options.validate().infer_error()?;
future_into_py(self_.py(), async move {
@@ -2244,20 +2248,24 @@ impl Table {
})
}
#[pyo3(signature = (application_json, output_name, auto_refresh_enabled=None, auto_refresh_interval_seconds=None))]
pub fn add_function_columns(
self_: PyRef<'_, Self>,
#[pyo3(signature = (application_json, output_name, auto_refresh_enabled=None, auto_refresh_interval_seconds=None, auto_refresh_resource_budget=None))]
pub fn add_function_columns<'py>(
self_: PyRef<'py, Self>,
application_json: String,
output_name: Option<String>,
auto_refresh_enabled: Option<bool>,
auto_refresh_interval_seconds: Option<u64>,
) -> PyResult<Bound<'_, PyAny>> {
auto_refresh_resource_budget: Option<Bound<'py, PyDict>>,
) -> PyResult<Bound<'py, PyAny>> {
let application =
lancedb::function::FunctionApplication::from_json(&application_json).infer_error()?;
let inner = self_.inner_ref()?.clone();
let options = ComputedColumnOptions {
auto_refresh_enabled: auto_refresh_enabled.unwrap_or(true),
auto_refresh_interval_seconds,
auto_refresh_resource_budget: parse_resource_budget(
auto_refresh_resource_budget.as_ref(),
)?,
};
options.validate().infer_error()?;
future_into_py(self_.py(), async move {