mirror of
https://github.com/lancedb/lancedb.git
synced 2026-11-14 06:36:54 +00:00
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:
@@ -60,9 +60,49 @@ columns through `col(...)`, keyed by the output column name. It commits a
|
||||
new, empty table version the same way a SQL expression does, so the usual
|
||||
[versioning](/management/table#versioning) applies.
|
||||
|
||||
Both a Function application and a SQL expression accept the same
|
||||
`auto_refresh_enabled` and `auto_refresh_interval_seconds` options, and both
|
||||
refresh and toggle the same way; see [Auto Refresh](/enterprise/auto-refresh).
|
||||
Both a Function application and a SQL expression accept
|
||||
`auto_refresh_enabled` and `auto_refresh_interval_seconds`, and both refresh
|
||||
and toggle the same way; see [Auto Refresh](/enterprise/auto-refresh).
|
||||
|
||||
Python and Rust Function declarations can also set
|
||||
`auto_refresh_resource_budget` to give their automatic refreshes an aggregate
|
||||
CPU, memory, and optional GPU budget. CPU and memory are required. The nested
|
||||
`gpu_count` defaults to zero, so omitting it requests CPU-only workers:
|
||||
|
||||
<CodeGroup>
|
||||
```python Python icon="python"
|
||||
table.add_columns(
|
||||
{"caption": version(jpeg=col("jpeg"))},
|
||||
auto_refresh_resource_budget={
|
||||
"cpu_millis": 8000,
|
||||
"memory_bytes": 16 * 1024**3,
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
```rust Rust icon="rust"
|
||||
table
|
||||
.add_columns()
|
||||
.function(function_application)
|
||||
.computed_options(ComputedColumnOptions {
|
||||
auto_refresh_enabled: true,
|
||||
auto_refresh_interval_seconds: None,
|
||||
auto_refresh_resource_budget: Some(
|
||||
FunctionWorkerResourceBudget::new(8000, 17_179_869_184),
|
||||
),
|
||||
})
|
||||
.execute()
|
||||
.await?;
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
This setting applies to every output in the Function binding. SQL-expression
|
||||
columns do not use Function workers, so they reject
|
||||
`auto_refresh_resource_budget` instead of accepting an option that has no
|
||||
effect. The separate `resource_budget` refresh option configures one explicit
|
||||
refresh and does not change this automatic-refresh declaration. The Node.js
|
||||
SDK does not currently expose a Function declaration through `addColumns`, so
|
||||
this option is not added to an unsupported surface.
|
||||
|
||||
## Fill a column with a SQL expression {#expressions}
|
||||
|
||||
|
||||
@@ -219,9 +219,11 @@ for changing only the aggregate GPU budget. It cannot be combined with the
|
||||
complete resource budget. Omitting both preserves the server's existing
|
||||
defaults.
|
||||
|
||||
These examples configure only the explicit refresh shown. They do not set the
|
||||
budget for an automatic refresh scheduled after `add_columns`; that remains a
|
||||
separate declaration and scheduler capability.
|
||||
These examples configure only the explicit refresh shown. A registered
|
||||
Function declaration can separately set `auto_refresh_resource_budget` on
|
||||
`add_columns`; that declaration controls the budget for its automatic
|
||||
refreshes. SQL-expression columns reject the automatic resource-budget option
|
||||
because they do not use Function workers.
|
||||
|
||||
`batch_size` caps rows in one Function call; LanceDB adapts actual call and
|
||||
work sizes. For Function columns, the refresh defaults to
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -3951,6 +3951,9 @@ impl<S: HttpSend> BaseTable for RemoteTable<S> {
|
||||
if let Some(interval) = options.auto_refresh_interval_seconds {
|
||||
body["auto_refresh_interval_seconds"] = serde_json::json!(interval);
|
||||
}
|
||||
if let Some(budget) = options.auto_refresh_resource_budget {
|
||||
body["auto_refresh_resource_budget"] = serde_json::json!(budget);
|
||||
}
|
||||
self.apply_branch_body(&mut body);
|
||||
let request = self
|
||||
.client
|
||||
@@ -10361,6 +10364,7 @@ mod tests {
|
||||
.computed_options(crate::table::computed_columns::ComputedColumnOptions {
|
||||
auto_refresh_enabled: false,
|
||||
auto_refresh_interval_seconds: interval,
|
||||
auto_refresh_resource_budget: None,
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
@@ -10797,6 +10801,11 @@ mod tests {
|
||||
))
|
||||
.unwrap();
|
||||
expected["auto_refresh_enabled"] = serde_json::json!(true);
|
||||
expected["auto_refresh_resource_budget"] = serde_json::json!({
|
||||
"cpu_millis": 8_000,
|
||||
"memory_bytes": 17_179_869_184_u64,
|
||||
"gpu_count": 2,
|
||||
});
|
||||
assert_eq!(actual, expected);
|
||||
http::Response::builder()
|
||||
.status(200)
|
||||
@@ -10824,6 +10833,14 @@ mod tests {
|
||||
let result = table
|
||||
.add_columns()
|
||||
.function(application)
|
||||
.computed_options(crate::table::computed_columns::ComputedColumnOptions {
|
||||
auto_refresh_enabled: true,
|
||||
auto_refresh_interval_seconds: None,
|
||||
auto_refresh_resource_budget: Some(
|
||||
crate::table::FunctionWorkerResourceBudget::new(8_000, 17_179_869_184)
|
||||
.with_gpu_count(2),
|
||||
),
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
@@ -171,7 +171,9 @@ impl AddColumnsBuilder {
|
||||
|
||||
/// Configure automatic refresh for every output declared in this call.
|
||||
/// Settings are stored with the declaration. They require computed or
|
||||
/// Function columns; LanceDB Embedded persists them without starting a scheduler.
|
||||
/// Function columns. LanceDB Embedded persists enabled and interval settings
|
||||
/// without starting a scheduler; an automatic resource budget requires an
|
||||
/// Enterprise registered Function and rejects SQL-expression columns.
|
||||
///
|
||||
/// ```
|
||||
/// # use lancedb::{Table, table::computed_columns::ComputedColumnOptions};
|
||||
@@ -180,6 +182,7 @@ impl AddColumnsBuilder {
|
||||
/// .computed_options(ComputedColumnOptions {
|
||||
/// auto_refresh_enabled: true,
|
||||
/// auto_refresh_interval_seconds: Some(300),
|
||||
/// auto_refresh_resource_budget: None,
|
||||
/// }).execute().await?;
|
||||
/// # Ok(())
|
||||
/// # }
|
||||
@@ -207,6 +210,13 @@ impl AddColumnsBuilder {
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
if options.auto_refresh_resource_budget.is_some() && function.is_none() {
|
||||
return Err(Error::InvalidInput {
|
||||
message:
|
||||
"auto_refresh_resource_budget requires a registered Function declaration"
|
||||
.into(),
|
||||
});
|
||||
}
|
||||
}
|
||||
let options = options.unwrap_or_default();
|
||||
|
||||
@@ -289,6 +299,7 @@ mod tests {
|
||||
let options = ComputedColumnOptions {
|
||||
auto_refresh_enabled: false,
|
||||
auto_refresh_interval_seconds: Some(300),
|
||||
auto_refresh_resource_budget: None,
|
||||
};
|
||||
let result = table
|
||||
.add_columns()
|
||||
@@ -373,6 +384,36 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn auto_refresh_resource_budget_requires_a_function() {
|
||||
let table = table_with_two_columns("budget_expression").await;
|
||||
let err = table
|
||||
.add_columns()
|
||||
.computed("doubled", "x * 2")
|
||||
.computed_options(ComputedColumnOptions {
|
||||
auto_refresh_enabled: true,
|
||||
auto_refresh_interval_seconds: None,
|
||||
auto_refresh_resource_budget: Some(
|
||||
crate::table::FunctionWorkerResourceBudget::new(1_000, 1_024),
|
||||
),
|
||||
})
|
||||
.execute()
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(&err, Error::InvalidInput { message } if message.contains("registered Function")),
|
||||
"{err:?}"
|
||||
);
|
||||
assert!(
|
||||
table
|
||||
.schema()
|
||||
.await
|
||||
.unwrap()
|
||||
.field_with_name("doubled")
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_requires_a_transform() {
|
||||
let table = table_with_two_columns("no_transform").await;
|
||||
|
||||
@@ -42,6 +42,7 @@ use serde_json::Value;
|
||||
|
||||
use crate::function::{FUNCTION_BLOB_V2_TYPE, FunctionApplication, FunctionBinding};
|
||||
use crate::materialized_view::without_declarations;
|
||||
use crate::table::FunctionWorkerResourceBudget;
|
||||
use crate::utils::resolve_arrow_field_path;
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -61,6 +62,9 @@ pub const INPUTS_META_KEY: &str = "computed_column.inputs";
|
||||
pub const AUTO_REFRESH_ENABLED_META_KEY: &str = "computed_column.auto_refresh_enabled";
|
||||
/// Minimum interval between successful automatic refreshes, in seconds.
|
||||
pub const AUTO_REFRESH_INTERVAL_META_KEY: &str = "computed_column.auto_refresh_interval_seconds";
|
||||
/// Aggregate worker-resource budget for automatic Function refreshes.
|
||||
pub const AUTO_REFRESH_RESOURCE_BUDGET_META_KEY: &str =
|
||||
"computed_column.auto_refresh_resource_budget";
|
||||
|
||||
/// Scheduling settings persisted on each computed output. LanceDB Embedded
|
||||
/// stores these settings; automatic scheduling requires LanceDB Enterprise.
|
||||
@@ -73,6 +77,10 @@ pub struct ComputedColumnOptions {
|
||||
/// When omitted, the server determines when to refresh.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_refresh_interval_seconds: Option<u64>,
|
||||
/// Aggregate worker-resource budget for automatic Function refreshes.
|
||||
/// This option requires a registered Function output.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub auto_refresh_resource_budget: Option<FunctionWorkerResourceBudget>,
|
||||
}
|
||||
|
||||
impl Default for ComputedColumnOptions {
|
||||
@@ -80,6 +88,7 @@ impl Default for ComputedColumnOptions {
|
||||
Self {
|
||||
auto_refresh_enabled: true,
|
||||
auto_refresh_interval_seconds: None,
|
||||
auto_refresh_resource_budget: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -95,6 +104,9 @@ impl ComputedColumnOptions {
|
||||
message: "auto_refresh_interval_seconds must be a positive integer no greater than u64::MAX / 1000".into(),
|
||||
});
|
||||
}
|
||||
if let Some(budget) = &self.auto_refresh_resource_budget {
|
||||
budget.validate()?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -112,6 +124,15 @@ impl ComputedColumnOptions {
|
||||
message: "auto_refresh_interval_seconds must be a positive integer".into(),
|
||||
})?);
|
||||
}
|
||||
if let Some(value) = metadata.get(AUTO_REFRESH_RESOURCE_BUDGET_META_KEY) {
|
||||
options.auto_refresh_resource_budget = Some(serde_json::from_str(value).map_err(
|
||||
|error| Error::InvalidInput {
|
||||
message: format!(
|
||||
"auto_refresh_resource_budget must be a valid resource budget: {error}"
|
||||
),
|
||||
},
|
||||
)?);
|
||||
}
|
||||
options.validate()?;
|
||||
Ok(options)
|
||||
}
|
||||
@@ -119,6 +140,7 @@ impl ComputedColumnOptions {
|
||||
/// Attach validated settings to newly declared computed output fields.
|
||||
pub fn apply_to_schema(&self, schema: &ArrowSchema) -> Result<ArrowSchema> {
|
||||
self.validate()?;
|
||||
let option_metadata = self.metadata()?;
|
||||
let fields = schema
|
||||
.fields()
|
||||
.iter()
|
||||
@@ -134,7 +156,7 @@ impl ComputedColumnOptions {
|
||||
return field.clone();
|
||||
}
|
||||
let mut metadata = field.metadata().clone();
|
||||
metadata.extend(self.metadata());
|
||||
metadata.extend(option_metadata.clone());
|
||||
Arc::new(field.as_ref().clone().with_metadata(metadata))
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
@@ -145,7 +167,7 @@ impl ComputedColumnOptions {
|
||||
}
|
||||
|
||||
/// Field metadata values shared by native and server declaration paths.
|
||||
pub fn metadata(&self) -> HashMap<String, String> {
|
||||
pub fn metadata(&self) -> Result<HashMap<String, String>> {
|
||||
let mut metadata = HashMap::from([(
|
||||
AUTO_REFRESH_ENABLED_META_KEY.into(),
|
||||
self.auto_refresh_enabled.to_string(),
|
||||
@@ -153,7 +175,15 @@ impl ComputedColumnOptions {
|
||||
if let Some(interval) = self.auto_refresh_interval_seconds {
|
||||
metadata.insert(AUTO_REFRESH_INTERVAL_META_KEY.into(), interval.to_string());
|
||||
}
|
||||
metadata
|
||||
if let Some(budget) = &self.auto_refresh_resource_budget {
|
||||
metadata.insert(
|
||||
AUTO_REFRESH_RESOURCE_BUDGET_META_KEY.into(),
|
||||
serde_json::to_string(budget).map_err(|error| Error::InvalidInput {
|
||||
message: format!("failed to encode auto_refresh_resource_budget: {error}"),
|
||||
})?,
|
||||
);
|
||||
}
|
||||
Ok(metadata)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2533,6 +2563,129 @@ pub(crate) mod test_support {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
#[test]
|
||||
fn computed_auto_refresh_resource_budget_metadata_round_trips() {
|
||||
let budget = crate::table::FunctionWorkerResourceBudget::new(8_000, 17_179_869_184)
|
||||
.with_gpu_count(2);
|
||||
let options = super::ComputedColumnOptions {
|
||||
auto_refresh_enabled: true,
|
||||
auto_refresh_interval_seconds: Some(300),
|
||||
auto_refresh_resource_budget: Some(budget),
|
||||
};
|
||||
assert_eq!(
|
||||
super::ComputedColumnOptions::from_metadata(&HashMap::new()).unwrap(),
|
||||
super::ComputedColumnOptions::default()
|
||||
);
|
||||
let metadata = options.metadata().unwrap();
|
||||
assert_eq!(
|
||||
metadata
|
||||
.get(super::AUTO_REFRESH_RESOURCE_BUDGET_META_KEY)
|
||||
.map(String::as_str),
|
||||
Some(r#"{"cpu_millis":8000,"memory_bytes":17179869184,"gpu_count":2}"#)
|
||||
);
|
||||
assert_eq!(
|
||||
super::ComputedColumnOptions::from_metadata(&metadata).unwrap(),
|
||||
options
|
||||
);
|
||||
|
||||
let mut omitted_gpu = metadata.clone();
|
||||
omitted_gpu.insert(
|
||||
super::AUTO_REFRESH_RESOURCE_BUDGET_META_KEY.into(),
|
||||
r#"{"cpu_millis":8000,"memory_bytes":17179869184}"#.into(),
|
||||
);
|
||||
let decoded = super::ComputedColumnOptions::from_metadata(&omitted_gpu).unwrap();
|
||||
assert_eq!(decoded.auto_refresh_resource_budget.unwrap().gpu_count, 0);
|
||||
assert_eq!(
|
||||
decoded
|
||||
.metadata()
|
||||
.unwrap()
|
||||
.get(super::AUTO_REFRESH_RESOURCE_BUDGET_META_KEY)
|
||||
.map(String::as_str),
|
||||
Some(r#"{"cpu_millis":8000,"memory_bytes":17179869184,"gpu_count":0}"#)
|
||||
);
|
||||
|
||||
for invalid in [
|
||||
r#"{"memory_bytes":17179869184,"gpu_count":2}"#,
|
||||
r#"{"cpu_millis":8000,"gpu_count":2}"#,
|
||||
r#"{"cpu_millis":"8000","memory_bytes":17179869184,"gpu_count":2}"#,
|
||||
r#"{"cpu_millis":-1,"memory_bytes":17179869184,"gpu_count":2}"#,
|
||||
r#"{"cpu_millis":18446744073709551616,"memory_bytes":17179869184,"gpu_count":2}"#,
|
||||
r#"{"cpu_millis":8000,"memory_bytes":17179869184,"gpu_count":-1}"#,
|
||||
r#"{"cpu_millis":8000,"memory_bytes":17179869184,"gpu_count":4294967296}"#,
|
||||
r#"{"cpu_millis":8000,"memory_bytes":17179869184,"gpu_count":2,"unknown":1}"#,
|
||||
r#"{"cpu_millis":0,"memory_bytes":17179869184,"gpu_count":2}"#,
|
||||
r#"{"cpu_millis":8000,"memory_bytes":0,"gpu_count":2}"#,
|
||||
] {
|
||||
let mut malformed = metadata.clone();
|
||||
malformed.insert(
|
||||
super::AUTO_REFRESH_RESOURCE_BUDGET_META_KEY.into(),
|
||||
invalid.into(),
|
||||
);
|
||||
assert!(
|
||||
super::ComputedColumnOptions::from_metadata(&malformed).is_err(),
|
||||
"accepted invalid budget: {invalid}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn computed_auto_refresh_resource_budget_applies_to_function_outputs() {
|
||||
let function_field = |name: &str, ordinal: u32| {
|
||||
ArrowField::new(name, DataType::Int32, true).with_metadata(HashMap::from([
|
||||
(
|
||||
super::COMPUTED_COLUMN_META_KEY.to_string(),
|
||||
"true".to_string(),
|
||||
),
|
||||
(
|
||||
super::KIND_META_KEY.to_string(),
|
||||
super::FUNCTION_KIND.to_string(),
|
||||
),
|
||||
(
|
||||
super::FUNCTION_BINDING_ID_META_KEY.to_string(),
|
||||
"binding-1".to_string(),
|
||||
),
|
||||
(
|
||||
super::FUNCTION_OUTPUT_ORDINAL_META_KEY.to_string(),
|
||||
ordinal.to_string(),
|
||||
),
|
||||
("custom".to_string(), "preserved".to_string()),
|
||||
]))
|
||||
};
|
||||
let schema = ArrowSchema::new(vec![
|
||||
ArrowField::new("input", DataType::Int32, true),
|
||||
function_field("first", 0),
|
||||
function_field("second", 1),
|
||||
function_field("assignment", super::FUNCTION_ASSIGNMENT_OUTPUT_ORDINAL),
|
||||
]);
|
||||
let budget = crate::table::FunctionWorkerResourceBudget::new(8_000, 17_179_869_184)
|
||||
.with_gpu_count(2);
|
||||
let updated = super::ComputedColumnOptions {
|
||||
auto_refresh_resource_budget: Some(budget),
|
||||
..Default::default()
|
||||
}
|
||||
.apply_to_schema(&schema)
|
||||
.unwrap();
|
||||
|
||||
for name in ["first", "second"] {
|
||||
let metadata = updated.field_with_name(name).unwrap().metadata();
|
||||
assert_eq!(
|
||||
metadata.get(super::AUTO_REFRESH_RESOURCE_BUDGET_META_KEY),
|
||||
Some(&serde_json::to_string(&budget).unwrap())
|
||||
);
|
||||
assert_eq!(
|
||||
metadata.get("custom").map(String::as_str),
|
||||
Some("preserved")
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!updated
|
||||
.field_with_name("assignment")
|
||||
.unwrap()
|
||||
.metadata()
|
||||
.contains_key(super::AUTO_REFRESH_RESOURCE_BUDGET_META_KEY)
|
||||
);
|
||||
}
|
||||
|
||||
/// The gate's reproducer: the validator applies the same schema-level
|
||||
/// guard declaring does, so a staging caller is refused before it commits
|
||||
/// anything else.
|
||||
|
||||
@@ -61,12 +61,14 @@ use crate::{Error, Result};
|
||||
/// values cap the Function workers together; they are not per-worker
|
||||
/// allocations. The default GPU budget is zero, which describes a CPU-only
|
||||
/// Function. This option is rejected by LanceDB Embedded refreshes.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
#[non_exhaustive]
|
||||
pub struct FunctionWorkerResourceBudget {
|
||||
pub cpu_millis: u64,
|
||||
pub memory_bytes: u64,
|
||||
/// Aggregate GPU budget across the refresh. Zero is CPU-only.
|
||||
#[serde(default)]
|
||||
pub gpu_count: u32,
|
||||
}
|
||||
|
||||
@@ -85,6 +87,21 @@ impl FunctionWorkerResourceBudget {
|
||||
self.gpu_count = gpu_count;
|
||||
self
|
||||
}
|
||||
|
||||
/// Require every per-tier resource limit to request at least one unit.
|
||||
pub fn validate(&self) -> Result<()> {
|
||||
if self.cpu_millis == 0 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "resource_budget.cpu_millis must be greater than zero".into(),
|
||||
});
|
||||
}
|
||||
if self.memory_bytes == 0 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "resource_budget.memory_bytes must be greater than zero".into(),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Options for refreshing a computed column.
|
||||
@@ -329,16 +346,7 @@ impl RefreshColumnOptions {
|
||||
});
|
||||
}
|
||||
if let Some(budget) = &self.resource_budget {
|
||||
if budget.cpu_millis == 0 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "resource_budget.cpu_millis must be greater than zero".into(),
|
||||
});
|
||||
}
|
||||
if budget.memory_bytes == 0 {
|
||||
return Err(Error::InvalidInput {
|
||||
message: "resource_budget.memory_bytes must be greater than zero".into(),
|
||||
});
|
||||
}
|
||||
budget.validate()?;
|
||||
}
|
||||
if let Some(gpu_class) = &self.gpu_class {
|
||||
validate_gpu_class_name(gpu_class)?;
|
||||
|
||||
Reference in New Issue
Block a user