feat: configure automatic refresh when declaring computed columns (#4607)

Add `auto_refresh_enabled` and `auto_refresh_interval_seconds` to the
Rust builder and Python sync/async add-column APIs for computed columns
and registered Function outputs.

Python users can configure refresh when declaring a column:

```python
table.add_columns(
    computed={"total": "price * quantity"},
    auto_refresh_enabled=True,
    auto_refresh_interval_seconds=300,
)
```

Omit the interval to let the server determine when to refresh:

```python
table.add_columns(computed={"discounted_total": "total * 0.9"})
```

The async API accepts the same options:

```python
await async_table.add_columns(
    computed={"total": "price * quantity"},
    auto_refresh_enabled=True,
    auto_refresh_interval_seconds=300,
)
```

For an existing registered `FunctionApplication`, the options also apply
to Function columns. Disabling automatic refresh still permits explicit
refresh:

```python
table.add_columns(
    {"features": function_application},
    auto_refresh_enabled=False,
    auto_refresh_interval_seconds=300,
)
table.refresh_column("features")
```

Options apply to every output declared by a call. Automatic refresh is
enabled by default; when the interval is omitted, the server determines
when to refresh. An explicit interval sets a minimum delay after a
successful refresh. The interval does not force recomputation of
unchanged inputs. Invalid intervals and options on one-time transforms
are rejected before declaration.

Native declarations persist `computed_column.auto_refresh_enabled` and
an explicitly supplied `computed_column.auto_refresh_interval_seconds`
in each output field’s metadata in the same commit as the declaration.
An omitted interval is neither stored nor sent in remote requests.
Remote declarations forward the settings to Cloud/Enterprise servers.
Local tables store these settings; automatic execution requires a
supporting server with scheduling enabled.
This commit is contained in:
Jack Ye
2026-10-21 20:27:11 -07:00
committed by GitHub
parent d97df0c82b
commit a4544c5472
13 changed files with 573 additions and 47 deletions
+9 -2
View File
@@ -501,10 +501,17 @@ class Table:
async def delete(self, filter: Union[str, PyExpr]) -> DeleteResult: ...
async def add_columns(self, columns: list[tuple[str, str]]) -> AddColumnsResult: ...
async def add_computed_columns(
self, columns: list[tuple[str, str]]
self,
columns: list[tuple[str, str]],
auto_refresh_enabled: Optional[bool] = None,
auto_refresh_interval_seconds: Optional[int] = None,
) -> AddColumnsResult: ...
async def add_function_columns(
self, application_json: str, output_name: Optional[str]
self,
application_json: str,
output_name: Optional[str],
auto_refresh_enabled: Optional[bool] = None,
auto_refresh_interval_seconds: Optional[int] = None,
) -> AddColumnsResult: ...
async def refresh_column(self, column: str) -> RefreshColumnResult: ...
async def refresh_column_async(self, column: str) -> Job: ...
+10 -1
View File
@@ -1001,8 +1001,17 @@ class RemoteTable(Table):
| None = None,
*,
computed: Dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms, computed=computed))
return LOOP.run(
self._table.add_columns(
transforms,
computed=computed,
auto_refresh_enabled=auto_refresh_enabled,
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
)
)
def refresh_column(self, column: str):
return LOOP.run(self._table.refresh_column(column))
+70 -3
View File
@@ -2561,6 +2561,8 @@ class Table(ABC):
| None = None,
*,
computed: Dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
):
"""
Add new columns with defined values.
@@ -2605,6 +2607,15 @@ class Table(ABC):
[`refresh_column_async`][lancedb.table.Table.refresh_column_async].
Cannot be combined with ``transforms``.
auto_refresh_enabled: bool, optional
Enable automatic refresh for each declared computed output. Defaults
to True. Local tables store this option; scheduling requires a server.
auto_refresh_interval_seconds: int, optional
Positive minimum interval after a successful refresh, in seconds.
When omitted, the server determines when to refresh. Applies to every
computed output in this call.
Scheduling options require computed or Function columns.
Returns
-------
AddColumnsResult
@@ -4803,8 +4814,17 @@ class LanceTable(Table):
| None = None,
*,
computed: Dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
) -> AddColumnsResult:
return LOOP.run(self._table.add_columns(transforms, computed=computed))
return LOOP.run(
self._table.add_columns(
transforms,
computed=computed,
auto_refresh_enabled=auto_refresh_enabled,
auto_refresh_interval_seconds=auto_refresh_interval_seconds,
)
)
def refresh_column(self, column: str) -> "RefreshColumnResult":
"""Fill a computed column's unfilled rows and recompute those whose
@@ -6852,6 +6872,8 @@ class AsyncTable:
| None = None,
*,
computed: dict[str, str] | None = None,
auto_refresh_enabled: bool | None = None,
auto_refresh_interval_seconds: int | None = None,
) -> AddColumnsResult:
"""
Add new columns with defined values.
@@ -6891,12 +6913,46 @@ class AsyncTable:
On LanceDB Cloud and Enterprise the expression is planned by
the server. Cannot be combined with ``transforms``.
auto_refresh_enabled: bool, optional
Enable automatic refresh for each computed output declared by this
call. Defaults to True. Local tables store the setting; automatic
scheduling requires Cloud or Enterprise.
auto_refresh_interval_seconds: int, optional
Positive minimum seconds after a successful refresh before another
automatic refresh is eligible. When omitted, the server determines when
to refresh. Unchanged inputs do
not force recomputation. Applies to every declared computed output.
These options cannot be used with one-time transforms or ordinary
all-null columns.
Examples
--------
>>> await table.add_columns( # doctest: +SKIP
... computed={"total": "price * quantity"},
... auto_refresh_enabled=True,
... auto_refresh_interval_seconds=300,
... )
Returns
-------
AddColumnsResult
version: the new version number of the table after adding columns.
"""
if auto_refresh_enabled is not None and type(auto_refresh_enabled) is not bool:
raise ValueError("auto_refresh_enabled must be a boolean")
if auto_refresh_interval_seconds is not None and (
type(auto_refresh_interval_seconds) is not int
or not 0 < auto_refresh_interval_seconds <= (2**64 - 1) // 1000
):
raise ValueError(
"auto_refresh_interval_seconds must be a positive integer "
"no greater than u64::MAX / 1000"
)
options_supplied = (
auto_refresh_enabled is not None
or auto_refresh_interval_seconds is not None
)
function_application = None
function_output_name = None
if isinstance(transforms, FunctionApplication):
@@ -6920,7 +6976,10 @@ class AsyncTable:
)
function_application._ensure_declarable()
return await self._inner.add_function_columns(
function_application.to_canonical_json(), function_output_name
function_application.to_canonical_json(),
function_output_name,
auto_refresh_enabled,
auto_refresh_interval_seconds,
)
if isinstance(transforms, pa.Field):
@@ -6934,7 +6993,15 @@ class AsyncTable:
raise ValueError(
"add_columns cannot take both transforms and computed columns"
)
return await self._inner.add_computed_columns(list(computed.items()))
return await self._inner.add_computed_columns(
list(computed.items()),
auto_refresh_enabled,
auto_refresh_interval_seconds,
)
if options_supplied:
raise ValueError(
"automatic refresh options require computed or Function columns"
)
if transforms is None:
raise ValueError("add_columns requires transforms or computed columns")
if isinstance(transforms, pa.Schema):
@@ -398,8 +398,21 @@ class _FunctionDeclarationInner:
def __init__(self):
self.calls = []
async def add_function_columns(self, application_json, output_name):
self.calls.append((json.loads(application_json), output_name))
async def add_function_columns(
self,
application_json,
output_name,
auto_refresh_enabled,
auto_refresh_interval_seconds,
):
self.calls.append(
(
json.loads(application_json),
output_name,
auto_refresh_enabled,
auto_refresh_interval_seconds,
)
)
return "declared"
@@ -420,14 +433,18 @@ 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)
bare = application._copy(update={"columns": {}}).rename(
columns={"normalized_text": "search_text"}
)
result = await table.add_columns(bare)
result = await table.add_columns(
bare, auto_refresh_enabled=False, auto_refresh_interval_seconds=300
)
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)
@pytest.mark.asyncio
+50
View File
@@ -4869,3 +4869,53 @@ async def test_refresh_column_async_job_async_table(tmp_path):
assert result.published_version == 4
assert await job.status() == "finished"
assert (await table.to_arrow())["tripled"].to_pylist() == [9]
@pytest.mark.parametrize("interval", [None, 300])
def test_computed_auto_refresh_options(tmp_path, interval):
db = lancedb.connect(tmp_path)
table = db.create_table("refresh_options", [{"x": 1}])
before = table.version
table.add_columns(
computed={"doubled": "x * 2", "tripled": "x * 3"},
auto_refresh_enabled=False,
auto_refresh_interval_seconds=interval,
)
assert table.version == before + 1
for column in ("doubled", "tripled"):
metadata = table.schema.field(column).metadata
assert metadata[b"computed_column.auto_refresh_enabled"] == b"false"
if interval is None:
assert b"computed_column.auto_refresh_interval_seconds" not in metadata
else:
assert metadata[b"computed_column.auto_refresh_interval_seconds"] == b"300"
table.refresh_column("doubled")
assert table.to_arrow()["doubled"].to_pylist() == [2]
@pytest.mark.parametrize("value", [0, -1, True, 1.5, "300", 2**64])
def test_computed_auto_refresh_rejects_invalid_interval(tmp_path, value):
table = lancedb.connect(tmp_path).create_table("invalid_options", [{"x": 1}])
with pytest.raises(ValueError, match="auto_refresh_interval_seconds"):
table.add_columns(
computed={"doubled": "x * 2"}, auto_refresh_interval_seconds=value
)
assert table.schema.names == ["x"]
def test_auto_refresh_options_require_computed_columns(tmp_path):
table = lancedb.connect(tmp_path).create_table("invalid_kind", [{"x": 1}])
with pytest.raises(ValueError, match="require computed"):
table.add_columns({"doubled": "x * 2"}, auto_refresh_enabled=True)
@pytest.mark.asyncio
async def test_async_computed_auto_refresh_uses_server_interval(tmp_path):
db = await lancedb.connect_async(tmp_path)
table = await db.create_table("server_interval", [{"x": 1}])
before = await table.version()
await table.add_columns(computed={"doubled": "x * 2"})
assert await table.version() == before + 1
metadata = (await table.schema()).field("doubled").metadata
assert metadata[b"computed_column.auto_refresh_enabled"] == b"true"
assert b"computed_column.auto_refresh_interval_seconds" not in metadata
+27 -2
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: Apache-2.0
// SPDX-FileCopyrightText: Copyright The LanceDB Authors
use lancedb::table::computed_columns::ComputedColumnOptions;
use std::{
collections::HashMap,
sync::{
@@ -1803,35 +1804,59 @@ impl Table {
})
}
#[pyo3(signature = (columns, auto_refresh_enabled=None, auto_refresh_interval_seconds=None))]
pub fn add_computed_columns(
self_: PyRef<'_, Self>,
columns: Vec<(String, String)>,
auto_refresh_enabled: Option<bool>,
auto_refresh_interval_seconds: Option<u64>,
) -> PyResult<Bound<'_, PyAny>> {
let inner = self_.inner_ref()?.clone();
let options = ComputedColumnOptions {
auto_refresh_enabled: auto_refresh_enabled.unwrap_or(true),
auto_refresh_interval_seconds,
};
options.validate().infer_error()?;
future_into_py(self_.py(), async move {
let mut builder = inner.add_columns();
for (name, expression) in columns {
builder = builder.computed(name, expression);
}
let result = builder.execute().await.infer_error()?;
let result = builder
.computed_options(options)
.execute()
.await
.infer_error()?;
Ok(AddColumnsResult::from(result))
})
}
#[pyo3(signature = (application_json, output_name, auto_refresh_enabled=None, auto_refresh_interval_seconds=None))]
pub fn add_function_columns(
self_: PyRef<'_, Self>,
application_json: String,
output_name: Option<String>,
auto_refresh_enabled: Option<bool>,
auto_refresh_interval_seconds: Option<u64>,
) -> PyResult<Bound<'_, 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,
};
options.validate().infer_error()?;
future_into_py(self_.py(), async move {
let builder = match output_name {
Some(name) => inner.add_columns().function_as(name, application),
None => inner.add_columns().function(application),
};
let result = builder.execute().await.infer_error()?;
let result = builder
.computed_options(options)
.execute()
.await
.infer_error()?;
Ok(AddColumnsResult::from(result))
})
}