fix: store namespaced view definitions under their own kind

Keeping `kind: "select"` for a namespaced definition was a silent downgrade
hazard: released readers drop unknown fields and resolve a select source at
the root, so a rolled-back worker could refresh a view from a same-name root
table and certify the wrong rows.

Root definitions keep the `select` form byte-for-byte; a namespaced source
is stored as `namespaced_select`, which older readers route to their
existing unrecognized-kind refusal. The reader also refuses a kind that
disagrees with its namespace, since no correct writer produces one. The
Python and Node definition parsers learn the new kind alongside the Rust
core, so every same-version reader agrees.
This commit is contained in:
Wyatt Alt
2026-08-31 18:23:40 +00:00
parent e87b5b7d92
commit 388c253f30
5 changed files with 169 additions and 6 deletions
+7 -1
View File
@@ -42,6 +42,8 @@ class MaterializedViewDefinition:
"""Cap on the number of rows the view holds."""
inputs: List[str] = field(default_factory=list)
"""Source columns the projections and filter read."""
source_namespace: List[str] = field(default_factory=list)
"""Namespace holding the source table; empty is the root namespace."""
def _definition_from_schema(
@@ -53,7 +55,10 @@ def _definition_from_schema(
raise ValueError(f"Table '{name}' is not a materialized view")
value = json.loads(raw)
kind = value.get("kind")
if kind != "select":
# "select" is the root-namespace form; "namespaced_select" carries a
# source namespace, a separate kind so older readers refuse it rather
# than silently resolving the source at the root.
if kind not in ("select", "namespaced_select"):
raise NotImplementedError(
f"materialized view '{name}' is defined by '{kind}', which this "
"version of lancedb cannot refresh"
@@ -66,6 +71,7 @@ def _definition_from_schema(
filter=value.get("filter"),
limit=value.get("limit"),
inputs=value.get("inputs", []),
source_namespace=value.get("source_namespace", []),
)
@@ -266,3 +266,38 @@ async def test_async_namespace_connection_materialized_views(tmp_path):
handle._route_pushdown_to_rust == through_namespace._route_pushdown_to_rust
)
assert handle._namespace_path == through_namespace._namespace_path
def test_namespaced_select_kind_is_read_and_unknown_kinds_are_refused():
import json
import pyarrow as pa
from lancedb.materialized_view import _definition_from_schema
def schema_with(definition: dict) -> pa.Schema:
return pa.schema([pa.field("id", pa.int32())]).with_metadata(
{b"mv.definition": json.dumps(definition).encode()}
)
# "namespaced_select" is the namespaced form of "select": same shape,
# a separate kind so readers that predate it refuse instead of
# resolving the source at the root.
definition = _definition_from_schema(
schema_with(
{
"kind": "namespaced_select",
"source_table": "people",
"source_namespace": ["ns"],
"projections": [{"output": "name", "expression": "name"}],
}
),
"v",
)
assert definition.source_table == "people"
assert definition.source_namespace == ["ns"]
with pytest.raises(NotImplementedError, match="cannot refresh"):
_definition_from_schema(
schema_with({"kind": "select_v3", "source_table": "people"}), "v"
)