mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 07:12:27 +00:00
## Summary
Materialize a source-preserving dedup result from an indexed, pinned
snapshot with a short declaration. The default greedy policy keeps the
smallest unassigned snapshot row ID and removes only its directly
matching neighbors: For A < B < C, an A–B–C chain without an A–C edge
keeps A and C.
```python
from lancedb import vector_dedup
view = db.create_materialized_view(
"images_clean",
vector_dedup("images", column="phash", distance_threshold=4),
)
cleaned = view.table
```
The Python source expression captures the source version once at
creation and
waits for the materialized result by default. Use `dataset_version=...`
for an
explicit snapshot, `with_no_data=True` for declaration only, or
`create_materialized_view_async(...)` for a job handle. AsyncConnection
supports
the same expression. It uses the existing MV REST endpoints and registry
jobs,
without opening a SQL query connection. Source names are exact
root-namespace
table names. Additional select/where/limit options are rejected for this
source.
The SQL declaration remains available:
```sql
CREATE MATERIALIZED VIEW images_clean AS
SELECT * FROM vector_dedup('images', 7, 'phash', 4);
REFRESH MATERIALIZED VIEW images_clean;
```
This is the host-facing SQL entry point. The MV builder now accepts a
typed native vector source; the Rust SDK also supports the same
definition through `MaterializedViewDefinition::from_sql` and
`prepare_definition`; `view.refresh().execute()` executes it locally.
Hosts distribute its units using their existing job infrastructure. The
raw `vector_duplicate_pairs` source remains available separately when
callers want the pair relation.
## Implementation and contract
- Use actual Rust Lance `13.0.0-beta.15` (`db53dcf367`); the native
partition primitive computes pairs from index representations without
source-vector aggregation or a Python pair loop.
- Extend partitioned MV refresh with two dependent waves. Physical
segment/partition tasks stream pairs through a bounded external sort,
select direct representatives and write per-source-fragment bitmap
artifacts. Source-fragment tasks start after every selection receipt
completes and stream retained original rows, including blob payloads,
into uncommitted output fragments.
- Reuse normal blob write coercion and row-mask scans, including stable
row-ID translation after compaction. Null vectors, isolated rows and
source deletions are preserved correctly. Conflicting representative
decisions across overlapping index scopes fail before publication.
- Expose `dependency_units()` and `write_refresh_partition_with_inputs`
alongside the existing plan/commit API. Receipts bind
snapshot/configuration/policy/destination generation. Only a complete
accepted set can publish; unfinished units restart, completed units can
be reused. There is no pair-position checkpoint.
- Use MV format 4 for dedup results and format 3 for native pairs;
Python and TypeScript metadata readers recognize both. No source
deletion or source row-ID migration occurs.
Only same-segment/same-partition edges are covered. Quantized scores use
symmetric represented-vector distances. Result materialization requires
V2 data storage and is initially a native MV source, not an ordinary
SELECT table function. Compressed vertex masks stay in memory; edge
sorting spills to local disk. Unreferenced artifacts use normal Lance
retention/cleanup and must be retained for the job's recovery window.
## Validation
AWS Linux validation:
- Native pair/result integration matrix passed: multiple
segments/partitions, exact thresholds, empty output, cross-batch data,
single-process pair parity, source preservation, lost receipts and
publication guards.
- Blob/null-vector/pinned-deletion tests passed with both row-ID modes,
including compaction that changes physical row addresses.
- Representative policy, forced external-sort spill and
conflicting/corrupt artifact tests passed.
- Full MV regression suite: 153 passed, 2 pre-existing ignored. Rust
formatting and Clippy passed.
- Python MV suite: 47 passed; TypeScript MV suite: 11 passed. Native
bindings were rebuilt in project-local environments; formatting, lint
and TypeScript docs passed.
- Rust declaration doctests: 3 passed, including the complete local
dedup example.
- Companion host validation: 41 MV job tests passed (1 pre-existing
ignored), full workspace Clippy passed, and the complete process-level
CLI E2E suite passed (3 tests). Recovery exercises both selection and
materialization waves.
- A real CIFAR-10 pHash run used five actual registry-worker owners,
completed 32 selection + 24 materialization tasks, and retained 49,846
of 50,000 rows with identical original values and image payloads.
Host workflow performance measurements are recorded in the companion
host PR. Cross-platform CI is still running. The previous remote-test
bootstrap failed before Cargo tests with `401 Bad credentials`; the
workflow fix described below is being validated by CI. No deployment or
merge is performed.
## Python source validation
AWS tests cover automatic and explicit snapshots, physical/stable row
IDs, local
and remote creation, sync/job/async-connection APIs, delayed refresh,
strict
options and identifier quoting. The native Python extension was rebuilt
through
the project's uv/maturin backend. Ruff, Pyright and the full MkDocs
API-reference
build passed; both new public symbols are rendered.
A real 50,000-image FEv2 run disabled both Python SQL entry points and
supplied an
unreachable SQL endpoint. The new API still materialized the same 49,846
retained
rows and original image payloads. Three recorded creation/refresh jobs
completed
56 tasks each on all five registry-worker owners, and remained on source
snapshot
3 after source latest advanced to 4. No worker or server wire-protocol
changes
were needed for this client entry point.
Previous CI authentication failure:
https://github.com/lancedb/lancedb/actions/runs/36334679276/job/108663151265.
## Review and CI fixes
- Validate all JSON merge source fields before projection, rejecting
unknown and duplicate names at every struct level. Missing/reordered
fields and ordinary JSON merges remain supported. A checked-in 12-case
integration matrix covers root fields, structs, lists, large lists,
fixed-size lists and map values; rejected writes leave the table version
and row count unchanged.
- Match namespace connection MV method signatures to
`MaterializedViewSource` in all four sync/async methods, fixing the
Python 3.10 import failure from runtime override checks.
- Restore the remote-test script's encrypted-CLI path: use the Actions
token for artifact download and pass `SOPHON_READ_TOKEN` under the
variable the decryption script consumes. The previous workflow omitted
that variable and attempted the private release path, failing with HTTP
401. On the final head, remote CI passes: 1,464 Rust unit tests, the
integration targets and doctests pass (3 unit tests ignored).
AWS validation for these fixes: both review reproducers failed before
their fixes and pass afterward; 55 merge tests, 29 cast tests and the
standalone 7-case admission regression pass. Python 3.10.21 imports
successfully; 90 MV/namespace tests, 14 JSON tests and four real
namespace dedup cases pass. After `794b97a0`, the native binding was
rebuilt again and the JSON suite rerun. Rust formatting/Clippy, Ruff and
Pyright pass. Sophon's locked test compilation passes against the final
SDK source for all seven affected crates. Extended runners, logs and
benchmark artifacts remain local.
Gatekeeper recommends approval on `794b97a0`, and the `breaking-change`
label is set. Final CI: **49 checks passed, 3 expected skips, no
failures or pending checks**, including remote tests, Python 3.10,
minimum dependencies and Windows ARM Rust tests. All wheel builds pass
after retrying the Linux matrix affected by transient AlmaLinux
repository-metadata 404s. All code-review threads are resolved. The PR
remains open for maintainer review and merge.
---------
Co-authored-by: Yang Cen <yangcen@Yangs-Mac-mini.local>
Co-authored-by: Lance Release <lance-dev@lancedb.com>
686 lines
23 KiB
Python
686 lines
23 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
|
|
import contextlib
|
|
import http.server
|
|
import json
|
|
import threading
|
|
|
|
import lancedb
|
|
import pytest
|
|
from lancedb.materialized_view import MaterializedViewDefinition
|
|
from lancedb.remote.db import RemoteDBConnection
|
|
|
|
|
|
STABLE_ROW_IDS = {"new_table_enable_stable_row_ids": "true"}
|
|
|
|
|
|
def make_db(tmp_path):
|
|
db = lancedb.connect(tmp_path, storage_options=STABLE_ROW_IDS)
|
|
db.create_table(
|
|
"people",
|
|
[
|
|
{"name": "ada", "age": 36},
|
|
{"name": "kid", "age": 7},
|
|
{"name": "grace", "age": 85},
|
|
],
|
|
)
|
|
return db
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def mock_remote_materialized_views():
|
|
requests = []
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_GET(self):
|
|
requests.append(self.path)
|
|
encoded = json.dumps({"views": ["daily_sales"]}).encode()
|
|
self.send_response(200)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
with http.server.HTTPServer(("localhost", 0), Handler) as server:
|
|
thread = threading.Thread(target=server.serve_forever)
|
|
thread.start()
|
|
try:
|
|
yield f"http://localhost:{server.server_address[1]}", requests
|
|
finally:
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def mock_remote_materialized_view_create():
|
|
requests = []
|
|
|
|
class Handler(http.server.BaseHTTPRequestHandler):
|
|
def log_message(self, *args):
|
|
pass
|
|
|
|
def do_POST(self):
|
|
length = int(self.headers.get("Content-Length", 0))
|
|
body = json.loads(self.rfile.read(length) or b"{}")
|
|
requests.append((self.path, body))
|
|
job_id = "mv-drop-123" if self.path.endswith("/drop") else "mv-create-123"
|
|
encoded = json.dumps({"job_id": job_id}).encode()
|
|
self.send_response(202)
|
|
self.send_header("Content-Type", "application/json")
|
|
self.send_header("Content-Length", str(len(encoded)))
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
with http.server.HTTPServer(("localhost", 0), Handler) as server:
|
|
thread = threading.Thread(target=server.serve_forever)
|
|
thread.start()
|
|
try:
|
|
yield f"http://localhost:{server.server_address[1]}", requests
|
|
finally:
|
|
server.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def test_remote_list_uses_namespace_route():
|
|
with mock_remote_materialized_views() as (host, requests):
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=host,
|
|
client_config={"retry_config": {"retries": 0}},
|
|
)
|
|
assert db.list_materialized_views() == ["daily_sales"]
|
|
assert requests == ["/v1/namespace/$/materialized_view/list"]
|
|
|
|
|
|
def test_remote_create_async_returns_server_job():
|
|
with mock_remote_materialized_view_create() as (host, requests):
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=host,
|
|
client_config={"retry_config": {"retries": 0}},
|
|
)
|
|
job = db.create_materialized_view_async("adults", "people", where="age >= 18")
|
|
assert job.id == "mv-create-123"
|
|
assert requests == [
|
|
(
|
|
"/v1/materialized_view/adults/create",
|
|
{"query": 'SELECT * FROM "people" WHERE age >= 18', "with_no_data": False},
|
|
)
|
|
]
|
|
|
|
|
|
def test_remote_drop_async_returns_server_job():
|
|
with mock_remote_materialized_view_create() as (host, requests):
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=host,
|
|
client_config={"retry_config": {"retries": 0}},
|
|
)
|
|
job = db.drop_materialized_view_async("adults")
|
|
assert job.id == "mv-drop-123"
|
|
assert requests == [("/v1/materialized_view/adults/drop", {})]
|
|
|
|
|
|
def test_sync_remote_create_uses_public_async_connection():
|
|
calls = []
|
|
|
|
class StubAsyncTable:
|
|
name = "adults"
|
|
|
|
class StubAsyncMaterializedView:
|
|
table = StubAsyncTable()
|
|
|
|
class StrictAsyncConnection:
|
|
async def create_materialized_view(
|
|
self,
|
|
name,
|
|
source,
|
|
*,
|
|
select=None,
|
|
where=None,
|
|
limit=None,
|
|
with_no_data=False,
|
|
):
|
|
calls.append((name, source, select, where, limit, with_no_data))
|
|
return StubAsyncMaterializedView()
|
|
|
|
async def drop_materialized_view(self, name, *, namespace_path=None):
|
|
calls.append(("drop", name, namespace_path))
|
|
|
|
db = RemoteDBConnection.__new__(RemoteDBConnection)
|
|
db._conn = StrictAsyncConnection()
|
|
db.db_name = "example"
|
|
db.serialize = lambda: "{}"
|
|
|
|
view = db.create_materialized_view(
|
|
"adults",
|
|
"people",
|
|
select=["name"],
|
|
where="age >= 18",
|
|
limit=10,
|
|
with_no_data=True,
|
|
)
|
|
assert view.name == "adults"
|
|
assert calls == [("adults", "people", ["name"], "age >= 18", 10, True)]
|
|
|
|
db.drop_materialized_view("adults", namespace_path=["analytics"])
|
|
assert calls[-1] == ("drop", "adults", ["analytics"])
|
|
|
|
|
|
def test_create_refresh_and_query(tmp_path):
|
|
db = make_db(tmp_path)
|
|
view = db.create_materialized_view(
|
|
"adults",
|
|
"people",
|
|
select=["name", ("shout", "upper(name)")],
|
|
where="age >= 18",
|
|
)
|
|
assert view.name == "adults"
|
|
assert view.table.count_rows() == 2
|
|
|
|
rows = view.table.search().to_list()
|
|
assert sorted(row["shout"] for row in rows) == ["ADA", "GRACE"]
|
|
|
|
|
|
def test_create_and_refresh_jobs(tmp_path):
|
|
db = make_db(tmp_path)
|
|
create_job = db.create_materialized_view_async(
|
|
"adults", "people", where="age >= 18", with_no_data=True
|
|
)
|
|
assert create_job.id is None
|
|
assert create_job.wait() is None
|
|
|
|
view = db.open_materialized_view("adults")
|
|
refresh_job = view.refresh_async()
|
|
assert refresh_job.id is None
|
|
result = refresh_job.wait()
|
|
assert result.mode == "rebuild"
|
|
assert result.rows_written == 2
|
|
assert view.table.count_rows() == 2
|
|
|
|
drop_job = db.drop_materialized_view_async("adults")
|
|
assert drop_job.id is None
|
|
assert drop_job.wait() is None
|
|
assert "adults" not in db.list_materialized_views()
|
|
|
|
|
|
def test_definition_round_trips(tmp_path):
|
|
db = make_db(tmp_path)
|
|
db.create_materialized_view("adults", "people", where="age >= 18")
|
|
|
|
view = db.open_materialized_view("adults")
|
|
assert view.definition == MaterializedViewDefinition(
|
|
query="SELECT name, age FROM people WHERE age >= 18"
|
|
)
|
|
|
|
|
|
def test_incremental_refresh_after_append(tmp_path):
|
|
db = make_db(tmp_path)
|
|
view = db.create_materialized_view("copy", "people", with_no_data=True)
|
|
view.refresh()
|
|
|
|
db.open_table("people").add([{"name": "alan", "age": 41}])
|
|
result = view.refresh()
|
|
assert result.mode == "incremental"
|
|
assert result.rows_written == 1
|
|
assert view.table.count_rows() == 4
|
|
|
|
assert view.refresh().mode == "no_op"
|
|
|
|
|
|
def test_incremental_refresh_after_update(tmp_path):
|
|
db = make_db(tmp_path)
|
|
view = db.create_materialized_view("copy", "people", with_no_data=True)
|
|
view.refresh()
|
|
|
|
db.open_table("people").update(where="name = 'kid'", values={"age": 8})
|
|
result = view.refresh()
|
|
assert result.mode == "incremental"
|
|
assert result.rows_written == 1
|
|
rows = view.table.search().to_list()
|
|
assert sorted(row["age"] for row in rows) == [8, 36, 85]
|
|
|
|
|
|
def test_legacy_storage_source_update_rebuilds(tmp_path):
|
|
db = lancedb.connect(
|
|
tmp_path,
|
|
storage_options={**STABLE_ROW_IDS, "new_table_data_storage_version": "legacy"},
|
|
)
|
|
db.create_table("people", [{"name": "ada", "age": 36}, {"name": "kid", "age": 7}])
|
|
view = db.create_materialized_view("copy", "people", with_no_data=True)
|
|
view.refresh()
|
|
|
|
db.open_table("people").update(where="name = 'kid'", values={"age": 8})
|
|
result = view.refresh()
|
|
assert result.mode == "rebuild"
|
|
rows = view.table.search().to_list()
|
|
assert sorted(row["age"] for row in rows) == [8, 36]
|
|
|
|
|
|
def test_list_and_not_a_view(tmp_path):
|
|
db = make_db(tmp_path)
|
|
db.create_materialized_view("adults", "people", where="age >= 18")
|
|
|
|
assert db.list_materialized_views() == ["adults"]
|
|
with pytest.raises(ValueError, match="not a materialized view"):
|
|
db.open_materialized_view("people")
|
|
with pytest.raises(ValueError, match="not a materialized view"):
|
|
db.drop_materialized_view("people")
|
|
|
|
db.drop_materialized_view("adults")
|
|
assert db.list_materialized_views() == []
|
|
|
|
|
|
def test_invalid_expression_fails_at_create(tmp_path):
|
|
db = make_db(tmp_path)
|
|
with pytest.raises(Exception, match="missing"):
|
|
db.create_materialized_view("bad", "people", select=[("x", "missing + 1")])
|
|
assert "bad" not in db.list_tables().tables
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_create_refresh_and_open(tmp_path):
|
|
db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS)
|
|
await db.create_table("people", [{"name": "ada", "age": 36}])
|
|
|
|
view = await db.create_materialized_view(
|
|
"shouts",
|
|
"people",
|
|
select=[("shout", "upper(name)")],
|
|
with_no_data=True,
|
|
)
|
|
result = await view.refresh()
|
|
assert result.mode == "rebuild"
|
|
assert result.rows_written == 1
|
|
|
|
reopened = await db.open_materialized_view("shouts")
|
|
definition = await reopened.definition()
|
|
assert definition.query == "SELECT upper(name) AS shout FROM people"
|
|
assert await db.list_materialized_views() == ["shouts"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_create_and_refresh_jobs(tmp_path):
|
|
db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS)
|
|
await db.create_table("people", [{"name": "ada", "age": 36}])
|
|
|
|
create_job = await db.create_materialized_view_async(
|
|
"adults", "people", with_no_data=True
|
|
)
|
|
assert create_job.id is None
|
|
assert await create_job.wait() is None
|
|
|
|
view = await db.open_materialized_view("adults")
|
|
refresh_job = await view.refresh_async()
|
|
assert refresh_job.id is None
|
|
result = await refresh_job.wait()
|
|
assert result.mode == "rebuild"
|
|
assert result.rows_written == 1
|
|
|
|
drop_job = await db.drop_materialized_view_async("adults")
|
|
assert drop_job.id is None
|
|
assert await drop_job.wait() is None
|
|
assert "adults" not in await db.list_materialized_views()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_incremental(tmp_path):
|
|
db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS)
|
|
await db.create_table("people", [{"name": "ada", "age": 36}])
|
|
view = await db.create_materialized_view("copy", "people", with_no_data=True)
|
|
await view.refresh()
|
|
|
|
table = await db.open_table("people")
|
|
await table.add([{"name": "alan", "age": 41}])
|
|
result = await view.refresh()
|
|
assert result.mode == "incremental"
|
|
assert result.rows_written == 1
|
|
|
|
|
|
def test_source_requires_stable_row_ids(tmp_path):
|
|
db = lancedb.connect(tmp_path)
|
|
db.create_table("plain", [{"x": 1}])
|
|
with pytest.raises(Exception, match="stable row ids"):
|
|
db.create_materialized_view("v", "plain")
|
|
|
|
|
|
def test_bare_select_names_are_quoted(tmp_path):
|
|
db = lancedb.connect(tmp_path, storage_options=STABLE_ROW_IDS)
|
|
db.create_table("odd_names", [{"order item": "widget", "select": 2}])
|
|
|
|
view = db.create_materialized_view(
|
|
"quoted",
|
|
"odd_names",
|
|
select=["order item", "select"],
|
|
with_no_data=True,
|
|
)
|
|
result = view.refresh()
|
|
assert result.rows_written == 1
|
|
rows = view.table.search().to_list()
|
|
assert rows[0]["order item"] == "widget"
|
|
assert rows[0]["select"] == 2
|
|
|
|
|
|
def test_scalar_select_is_one_column(tmp_path):
|
|
db = make_db(tmp_path)
|
|
view = db.create_materialized_view("just_name", "people", select="name")
|
|
view.refresh()
|
|
rows = view.table.search().to_list()
|
|
assert set(rows[0]) - {"__source_row_id"} == {"name"}
|
|
assert sorted(row["name"] for row in rows) == ["ada", "grace", "kid"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_scalar_select_is_one_column(tmp_path):
|
|
db = await lancedb.connect_async(tmp_path, storage_options=STABLE_ROW_IDS)
|
|
await db.create_table("people", [{"name": "ada", "age": 36}])
|
|
view = await db.create_materialized_view("just_name", "people", select="name")
|
|
await view.refresh()
|
|
rows = await view.table.query().to_list()
|
|
assert set(rows[0]) - {"__source_row_id"} == {"name"}
|
|
|
|
|
|
def test_limit_above_i64_max_is_refused(tmp_path):
|
|
db = make_db(tmp_path)
|
|
with pytest.raises(ValueError, match="exceeds the maximum"):
|
|
db.create_materialized_view("too_big", "people", limit=2**63)
|
|
# The boundary is fine, and zero still means an empty view.
|
|
db.create_materialized_view("at_max", "people", limit=2**63 - 1)
|
|
empty = db.create_materialized_view("none", "people", limit=0)
|
|
empty.refresh()
|
|
assert empty.table.count_rows() == 0
|
|
|
|
|
|
def _namespace_db(tmp_path):
|
|
return lancedb.connect_namespace(
|
|
"dir",
|
|
{"root": str(tmp_path)},
|
|
storage_options=STABLE_ROW_IDS,
|
|
)
|
|
|
|
|
|
def test_namespace_connection_materialized_views(tmp_path):
|
|
db = _namespace_db(tmp_path)
|
|
db.create_table(
|
|
"people",
|
|
[{"name": "ada", "age": 36}, {"name": "kid", "age": 7}],
|
|
storage_options=STABLE_ROW_IDS,
|
|
)
|
|
|
|
view = db.create_materialized_view("adults", "people", where="age >= 18")
|
|
view.refresh()
|
|
assert view.table.count_rows() == 1
|
|
assert db.list_materialized_views() == ["adults"]
|
|
|
|
reopened = db.open_materialized_view("adults")
|
|
assert reopened.definition.query.startswith("SELECT name, age FROM ")
|
|
with pytest.raises(ValueError, match="not a materialized view"):
|
|
db.open_materialized_view("people")
|
|
|
|
create_job = db.create_materialized_view_async(
|
|
"job_view", "people", with_no_data=True
|
|
)
|
|
assert create_job.wait() is None
|
|
refresh_job = db.open_materialized_view("job_view").refresh_async()
|
|
assert refresh_job.wait().rows_written == 2
|
|
|
|
assert db.drop_materialized_view_async("job_view").wait() is None
|
|
db.drop_materialized_view("adults")
|
|
assert db.list_materialized_views() == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_namespace_connection_materialized_views(tmp_path):
|
|
db = lancedb.connect_namespace_async(
|
|
"dir",
|
|
{"root": str(tmp_path)},
|
|
storage_options=STABLE_ROW_IDS,
|
|
)
|
|
await db.create_table(
|
|
"people",
|
|
[{"name": "ada", "age": 36}, {"name": "kid", "age": 7}],
|
|
storage_options=STABLE_ROW_IDS,
|
|
)
|
|
|
|
view = await db.create_materialized_view("adults", "people", where="age >= 18")
|
|
await view.refresh()
|
|
assert await view.table.count_rows() == 1
|
|
assert await db.list_materialized_views() == ["adults"]
|
|
|
|
reopened = await db.open_materialized_view("adults")
|
|
assert (await reopened.definition()).query.startswith("SELECT name, age FROM ")
|
|
|
|
# The view's table came through the namespace, not straight from the
|
|
# inner connection: a bare inner table carries no namespace context, so
|
|
# its pushdown routing differs from a table the namespace opened.
|
|
through_namespace = await db.open_table("adults")
|
|
for handle in (view.table, reopened.table):
|
|
assert (
|
|
handle._route_pushdown_to_rust == through_namespace._route_pushdown_to_rust
|
|
)
|
|
assert handle._namespace_path == through_namespace._namespace_path
|
|
|
|
create_job = await db.create_materialized_view_async(
|
|
"job_view", "people", with_no_data=True
|
|
)
|
|
assert await create_job.wait() is None
|
|
job_view = await db.open_materialized_view("job_view")
|
|
refresh_job = await job_view.refresh_async()
|
|
assert (await refresh_job.wait()).rows_written == 2
|
|
|
|
drop_job = await db.drop_materialized_view_async("job_view")
|
|
assert await drop_job.wait() is None
|
|
await db.drop_materialized_view("adults")
|
|
assert await db.list_materialized_views() == []
|
|
|
|
|
|
def test_stored_queries_and_legacy_layouts_are_read():
|
|
import json
|
|
|
|
import pyarrow as pa
|
|
|
|
from lancedb.materialized_view import _definition_from_schema
|
|
|
|
def read(definition: dict) -> MaterializedViewDefinition:
|
|
schema = pa.schema([pa.field("id", pa.int32())]).with_metadata(
|
|
{b"mv.definition": json.dumps(definition).encode()}
|
|
)
|
|
return _definition_from_schema(schema, "v")
|
|
|
|
query = "SELECT id, c.chunk FROM ns.docs, UNNEST(chunks) AS c WHERE id > 1"
|
|
assert read({"format": 1, "query": query}).query == query
|
|
|
|
# The structured layout written before the format number reads as the
|
|
# query it described, under either of its kind tags.
|
|
assert (
|
|
read(
|
|
{
|
|
"kind": "namespaced_select",
|
|
"source_table": "people",
|
|
"source_namespace": ["ns"],
|
|
"projections": [
|
|
{"output": "name", "expression": "`name`"},
|
|
{"output": "Shout", "expression": "upper(name)"},
|
|
],
|
|
"filter": "age >= 18",
|
|
"limit": 10,
|
|
}
|
|
).query
|
|
== "SELECT `name`, upper(name) AS `Shout` FROM ns.people "
|
|
"WHERE age >= 18 LIMIT 10"
|
|
)
|
|
assert read({"kind": "select", "source_table": "people"}).query == (
|
|
"SELECT * FROM people"
|
|
)
|
|
|
|
for fmt, function in [(3, "vector_duplicate_pairs"), (4, "vector_dedup")]:
|
|
native = f"SELECT * FROM {function}('images', 3, 'phash', 4)"
|
|
assert read({"format": fmt, "query": native}).query == native
|
|
|
|
# A newer writer's layout is reported, never guessed at.
|
|
for newer in (
|
|
{"format": 5, "query": query},
|
|
{"kind": "select_v3", "source_table": "people"},
|
|
):
|
|
with pytest.raises(NotImplementedError, match="cannot refresh"):
|
|
read(newer)
|
|
|
|
|
|
def indexed_dedup_source(db, name="images"):
|
|
import pyarrow as pa
|
|
from lancedb.index import IvfFlat
|
|
|
|
vectors = pa.FixedSizeListArray.from_arrays(
|
|
pa.array(
|
|
[v for i in range(256) for v in (float(i if i < 3 else i * 10), 0.0)],
|
|
type=pa.float32(),
|
|
),
|
|
2,
|
|
)
|
|
table = db.create_table(name, pa.table({"id": range(256), "vector": vectors}))
|
|
table.create_index("vector", config=IvfFlat(num_partitions=1))
|
|
return table
|
|
|
|
|
|
@pytest.mark.parametrize("stable", [False, True])
|
|
def test_vector_dedup_source_captures_snapshot_and_preserves_source(tmp_path, stable):
|
|
db = lancedb.connect(
|
|
tmp_path,
|
|
storage_options={"new_table_enable_stable_row_ids": str(stable).lower()},
|
|
)
|
|
source = indexed_dedup_source(db, "images.with.dot")
|
|
version = source.version
|
|
expression = lancedb.vector_dedup(
|
|
source.name, column="vector", distance_threshold=1
|
|
)
|
|
view = db.create_materialized_view("clean", expression)
|
|
assert "vector_dedup(" in view.definition.query
|
|
assert f", {version}, 'vector', 1.0)" in view.definition.query
|
|
assert view.table.count_rows() == 255
|
|
assert [r["id"] for r in view.table.search().where("id < 3").to_list()] == [0, 2]
|
|
assert source.version == version
|
|
assert source.count_rows() == 256
|
|
|
|
source.delete("id = 0")
|
|
view.refresh_async().wait()
|
|
assert view.table.search().where("id = 0").to_list() == [
|
|
{"id": 0, "vector": [0.0, 0.0]}
|
|
]
|
|
assert expression.dataset_version is None # reusable immutable declaration
|
|
|
|
|
|
def test_vector_dedup_create_job_and_explicit_snapshot(tmp_path):
|
|
db = lancedb.connect(tmp_path)
|
|
source = indexed_dedup_source(db)
|
|
version = source.version
|
|
source.delete("id = 0")
|
|
job = db.create_materialized_view_async(
|
|
"clean",
|
|
lancedb.vector_dedup(
|
|
"images", column="vector", distance_threshold=1, dataset_version=version
|
|
),
|
|
)
|
|
assert job.wait() is None
|
|
view = db.open_materialized_view("clean")
|
|
assert view.table.count_rows() == 255
|
|
assert len(view.table.search().where("id = 0").to_list()) == 1
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_vector_dedup_declaration_and_creation(tmp_path):
|
|
sync_db = lancedb.connect(tmp_path)
|
|
source = indexed_dedup_source(sync_db)
|
|
db = await lancedb.connect_async(tmp_path)
|
|
expression = lancedb.vector_dedup("images", column="vector", distance_threshold=1)
|
|
view = await db.create_materialized_view("clean", expression, with_no_data=True)
|
|
assert await view.table.count_rows() == 0
|
|
# The snapshot is captured at declaration even if refresh comes later.
|
|
source.delete("id = 0")
|
|
job = await view.refresh_async()
|
|
await job.wait()
|
|
assert await view.table.count_rows() == 255
|
|
another = await db.create_materialized_view_async(
|
|
"later", expression, with_no_data=True
|
|
)
|
|
await another.wait()
|
|
later = await db.open_materialized_view("later")
|
|
assert (await later.definition()).query != (await view.definition()).query
|
|
assert await later.table.count_rows() == 0
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"options", [{"select": ["id"]}, {"select": []}, {"where": "id > 0"}, {"limit": 1}]
|
|
)
|
|
def test_vector_dedup_rejects_extra_clauses_before_opening_source(tmp_path, options):
|
|
db = lancedb.connect(tmp_path)
|
|
expression = lancedb.vector_dedup("missing", column="vector", distance_threshold=1)
|
|
with pytest.raises(ValueError, match="cannot be combined"):
|
|
db.create_materialized_view("bad", expression, **options)
|
|
assert db.list_tables().tables == []
|
|
|
|
|
|
@pytest.mark.parametrize("version", [0, -1, True, 1.5, "2", 2**64])
|
|
def test_vector_dedup_rejects_invalid_snapshot(version):
|
|
with pytest.raises(ValueError, match="dataset_version"):
|
|
lancedb.vector_dedup(
|
|
"images", column="vector", distance_threshold=1, dataset_version=version
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"threshold", [float("nan"), float("inf"), -float("inf"), 1e100, 10**1000, True, "4"]
|
|
)
|
|
def test_vector_dedup_rejects_invalid_threshold(threshold):
|
|
with pytest.raises((ValueError, TypeError), match="distance_threshold"):
|
|
lancedb.vector_dedup("images", column="vector", distance_threshold=threshold)
|
|
|
|
|
|
def test_vector_dedup_descriptor_is_frozen():
|
|
from dataclasses import FrozenInstanceError
|
|
|
|
expression = lancedb.vector_dedup("images", column="vector", distance_threshold=1)
|
|
with pytest.raises(FrozenInstanceError):
|
|
expression.column = "other"
|
|
|
|
|
|
@pytest.mark.parametrize("with_no_data", [False, True])
|
|
def test_remote_vector_dedup_uses_mv_endpoint_without_sql_client(with_no_data):
|
|
with mock_remote_materialized_view_create() as (host, requests):
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=host,
|
|
sql_host_override="grpc://127.0.0.1:1",
|
|
client_config={"retry_config": {"retries": 0}},
|
|
)
|
|
job = db.create_materialized_view_async(
|
|
"clean",
|
|
lancedb.vector_dedup(
|
|
"images.with'dot",
|
|
column="vector'field",
|
|
distance_threshold=4,
|
|
dataset_version=7,
|
|
),
|
|
with_no_data=with_no_data,
|
|
)
|
|
assert job.id == "mv-create-123"
|
|
assert requests == [
|
|
(
|
|
"/v1/materialized_view/clean/create",
|
|
{
|
|
"query": (
|
|
"SELECT * FROM vector_dedup('`images.with''dot`', "
|
|
"7, 'vector''field', 4.0)"
|
|
),
|
|
"with_no_data": with_no_data,
|
|
},
|
|
)
|
|
]
|