mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-13 07:12:27 +00:00
The write path these APIs drive is Lance's MemWAL, and "LSM" described its shape rather than naming it. The surface now says MemWAL, and turning it on and off reads as enabling a feature rather than installing a spec. | Before | After | |---|---| | `set_lsm_write_spec` | `enable_memwal` | | `unset_lsm_write_spec` | `disable_memwal` | | `get_lsm_write_spec` | `get_memwal_spec` | | `flush_lsm` / `compact_lsm` / `checkpoint_lsm` | `flush_memwal` / `compact_memwal` / `checkpoint_memwal` | | `get_lsm_stats` | `get_memwal_stats` | | `close_lsm_writers` | `close_memwal_writers` | | `use_lsm` (query, merge insert) | `use_memwal` | | `LsmWriteSpec` / `LsmStats` | `MemWalWriteSpec` / `MemWalStats` | | Java `LanceDbTableLsm` | `LanceDbTableMemWal` | TypeScript and Java use camelCase (`enableMemWal`, `getMemWalStats`, ...); the napi methods set `js_name` so the generated names keep the `MemWal` casing. The flush/compact/checkpoint methods keep a `_memwal` suffix so `compact_memwal` is not confused with `compact_files` and `optimize`, which compact the base table. ## Wire The remote routes and JSON envelopes move with the SDKs and must ship together with the matching server change: - `/set_lsm_write_spec/` -> `/enable_memwal/`, `/unset_lsm_write_spec/` -> `/disable_memwal/`, `/get_lsm_write_spec/` -> `/get_memwal_spec/`, `/flush_lsm/`, `/compact_lsm/`, `/get_lsm_stats/` -> `*_memwal/` - response keys `lsm_write_spec` -> `memwal_spec`, `lsm_stats` -> `memwal_stats`; query and merge-insert body field `use_lsm` -> `use_memwal` ## Also fixed The previous rename turned the Java `bucket` sharding mode into `tableShard` in the javadoc example and tests, and the test fixtures sent `tableShards` where the parser reads `table_shards`, so the Java tests did not compile. Both are restored. Internal names (`LsmMode`, `LsmScanner`, the `merge::lsm` and `query::lsm` modules) are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
3859 lines
139 KiB
Python
3859 lines
139 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
import contextlib
|
|
from datetime import timedelta
|
|
import http.server
|
|
import json
|
|
import multiprocessing as mp
|
|
import pickle
|
|
import re
|
|
import sys
|
|
import threading
|
|
import time
|
|
from unittest.mock import MagicMock, patch
|
|
import uuid
|
|
from urllib.parse import parse_qs, urlparse
|
|
from packaging.version import Version
|
|
|
|
import lancedb
|
|
import numpy as np
|
|
from lancedb.conftest import MockNonNormTextEmbeddingFunction, MockTextEmbeddingFunction
|
|
from lancedb.embeddings import EmbeddingFunctionConfig, EmbeddingFunctionRegistry
|
|
from lancedb.query import (
|
|
AsyncQuery,
|
|
ColumnOrdering,
|
|
LanceVectorQueryBuilder,
|
|
MatchQuery,
|
|
)
|
|
from lancedb.remote import ClientConfig
|
|
from lancedb.remote.errors import HttpError, RetryError
|
|
import pytest
|
|
import pyarrow as pa
|
|
|
|
|
|
def make_mock_http_handler(handler):
|
|
class MockLanceDBHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
handler(self)
|
|
|
|
def do_POST(self):
|
|
handler(self)
|
|
|
|
return MockLanceDBHandler
|
|
|
|
|
|
@pytest.mark.parametrize("db_name", ["a" * 64, "invalid..database"])
|
|
def test_connect_rejects_invalid_cloud_dns_hostname(db_name):
|
|
with pytest.raises(ValueError, match="DNS labels must contain 1 to 63 bytes"):
|
|
lancedb.connect(f"db://{db_name}", api_key="fake")
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def mock_lancedb_connection(handler):
|
|
with http.server.HTTPServer(
|
|
("localhost", 0), make_mock_http_handler(handler)
|
|
) as server:
|
|
port = server.server_address[1]
|
|
handle = threading.Thread(target=server.serve_forever)
|
|
handle.start()
|
|
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 2},
|
|
"timeout_config": {
|
|
"connect_timeout": 1,
|
|
},
|
|
},
|
|
)
|
|
|
|
try:
|
|
yield db
|
|
finally:
|
|
server.shutdown()
|
|
handle.join()
|
|
|
|
|
|
@contextlib.asynccontextmanager
|
|
async def mock_lancedb_connection_async(handler, **client_config):
|
|
with http.server.HTTPServer(
|
|
("localhost", 0), make_mock_http_handler(handler)
|
|
) as server:
|
|
port = server.server_address[1]
|
|
handle = threading.Thread(target=server.serve_forever)
|
|
handle.start()
|
|
|
|
db = await lancedb.connect_async(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 2},
|
|
"timeout_config": {
|
|
"connect_timeout": 1,
|
|
},
|
|
**client_config,
|
|
},
|
|
)
|
|
|
|
try:
|
|
yield db
|
|
finally:
|
|
server.shutdown()
|
|
handle.join()
|
|
|
|
|
|
def paginated_table_listing_handler(request):
|
|
url = urlparse(request.path)
|
|
assert url.path in {
|
|
"/v1/table/",
|
|
"/v1/namespace/$/table/list",
|
|
"/v1/namespace/n/table/list",
|
|
}
|
|
query = parse_qs(url.query)
|
|
limit = int(query.get("limit", [10])[0])
|
|
assert limit > 0
|
|
token = query.get("page_token", ["page-0"])[0]
|
|
start = int(token.removeprefix("page-"))
|
|
end = min(start + limit, 130)
|
|
body = {"tables": [f"t{i:03}" for i in range(start, end)]}
|
|
if end < 130:
|
|
body["page_token"] = f"page-{end}"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(body).encode())
|
|
|
|
|
|
@pytest.mark.parametrize("namespace", [[], ["n"]])
|
|
def test_remote_table_listing_defaults_and_zero_limit(namespace):
|
|
names = [f"t{i:03}" for i in range(130)]
|
|
with mock_lancedb_connection(paginated_table_listing_handler) as db:
|
|
assert db.table_names(namespace_path=namespace) == names
|
|
assert db.table_names(limit=None, namespace_path=namespace) == names
|
|
assert db.table_names(limit=0, namespace_path=namespace) == []
|
|
first = db.list_tables(namespace_path=namespace)
|
|
assert first.tables == names[:100]
|
|
assert first.page_token == "page-100"
|
|
second = db.list_tables(namespace_path=namespace, page_token=first.page_token)
|
|
assert second.tables == names[100:]
|
|
assert second.page_token is None
|
|
assert db.list_tables(namespace_path=namespace, limit=200).tables == names
|
|
empty = db.list_tables(namespace_path=namespace, limit=0)
|
|
assert empty.tables == []
|
|
assert empty.page_token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("namespace", [[], ["n"]])
|
|
async def test_async_remote_table_listing_defaults_and_zero_limit(namespace):
|
|
names = [f"t{i:03}" for i in range(130)]
|
|
async with mock_lancedb_connection_async(paginated_table_listing_handler) as db:
|
|
assert await db.table_names(namespace_path=namespace) == names
|
|
assert await db.table_names(limit=0, namespace_path=namespace) == []
|
|
first = await db.list_tables(namespace_path=namespace)
|
|
assert first.tables == names[:100]
|
|
assert first.page_token == "page-100"
|
|
second = await db.list_tables(
|
|
namespace_path=namespace, page_token=first.page_token
|
|
)
|
|
assert second.tables == names[100:]
|
|
assert second.page_token is None
|
|
empty = await db.list_tables(namespace_path=namespace, limit=0)
|
|
assert empty.tables == []
|
|
assert empty.page_token is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_remote_db():
|
|
def handler(request):
|
|
# We created a UUID request id
|
|
request_id = request.headers["x-request-id"]
|
|
assert uuid.UUID(request_id).version == 4
|
|
|
|
# We set a user agent with the current library version
|
|
user_agent = request.headers["User-Agent"]
|
|
assert user_agent == f"LanceDB-Python-Client/{lancedb.__version__}"
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
table_names = await db.table_names()
|
|
assert table_names == []
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"alteration, match",
|
|
[
|
|
({"path": "id"}, "One of rename, nullable or data_type"),
|
|
(
|
|
{"path": "id", "nulable": False}, # spellchecker:disable-line
|
|
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
|
),
|
|
(
|
|
{
|
|
"path": "id",
|
|
"rename": "new_id",
|
|
"nulable": False, # spellchecker:disable-line
|
|
},
|
|
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
|
),
|
|
],
|
|
)
|
|
def test_remote_alter_columns_rejects_invalid_before_request(alteration, match):
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request.path)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
requests.clear()
|
|
|
|
with pytest.raises(ValueError, match=match):
|
|
table.alter_columns(alteration)
|
|
|
|
assert requests == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"alteration, match",
|
|
[
|
|
({"path": "id"}, "One of rename, nullable or data_type"),
|
|
(
|
|
{"path": "id", "nulable": False}, # spellchecker:disable-line
|
|
"Unknown column alteration key 'nulable'", # spellchecker:disable-line
|
|
),
|
|
],
|
|
)
|
|
async def test_async_remote_alter_columns_rejects_invalid_before_request(
|
|
alteration, match
|
|
):
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request.path)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
table = await db.open_table("test")
|
|
requests.clear()
|
|
|
|
with pytest.raises(ValueError, match=match):
|
|
await table.alter_columns(alteration)
|
|
|
|
assert requests == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_checkout():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
response = json.dumps({"version": 42, "schema": {"fields": []}})
|
|
request.wfile.write(response.encode())
|
|
return
|
|
|
|
content_len = int(request.headers.get("Content-Length"))
|
|
body = request.rfile.read(content_len)
|
|
body = json.loads(body)
|
|
|
|
print("body is", body)
|
|
|
|
count = 0
|
|
if body["version"] == 1:
|
|
count = 100
|
|
elif body["version"] == 2:
|
|
count = 200
|
|
elif body["version"] is None:
|
|
count = 300
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(count).encode())
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
table = await db.open_table("test")
|
|
assert await table.count_rows() == 300
|
|
await table.checkout(1)
|
|
assert await table.count_rows() == 100
|
|
await table.checkout(2)
|
|
assert await table.count_rows() == 200
|
|
await table.checkout_latest()
|
|
assert await table.count_rows() == 300
|
|
|
|
|
|
def _branch_open_handler(request):
|
|
if "/branches/list" in request.path:
|
|
body = json.dumps(
|
|
{
|
|
"branches": {
|
|
"exp": {
|
|
"parentBranch": None,
|
|
"parentVersion": 1,
|
|
"createAt": 1,
|
|
"manifestSize": 1,
|
|
}
|
|
}
|
|
}
|
|
).encode()
|
|
else:
|
|
# describe (table open + version/branch validation)
|
|
body = json.dumps({"version": 2, "schema": {"fields": []}}).encode()
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(body)
|
|
|
|
|
|
def test_remote_open_table_branch_and_version():
|
|
with mock_lancedb_connection(_branch_open_handler) as db:
|
|
# version-only (and "main" + version) time-travels the main chain
|
|
assert db.open_table("test", version=2) is not None
|
|
assert db.open_table("test", branch="main", version=2).current_branch() is None
|
|
|
|
# a non-main branch opens a handle scoped to that branch, with or
|
|
# without a version
|
|
assert db.open_table("test", branch="exp").current_branch() == "exp"
|
|
assert db.open_table("test", branch="exp", version=2).current_branch() == "exp"
|
|
|
|
|
|
def test_remote_table_branches_sync():
|
|
# Branch CRUD + current_branch on the sync RemoteTable. The handle returned
|
|
# by create/checkout must stay a RemoteTable scoped to the branch.
|
|
from lancedb.remote.table import RemoteTable
|
|
|
|
def handler(request):
|
|
if "/branches/list" in request.path:
|
|
body = json.dumps(
|
|
{
|
|
"branches": {
|
|
"exp": {
|
|
"parentBranch": None,
|
|
"parentVersion": 1,
|
|
"createAt": 1,
|
|
"manifestSize": 1,
|
|
}
|
|
}
|
|
}
|
|
).encode()
|
|
elif "/branches/create" in request.path or "/branches/delete" in request.path:
|
|
body = b"{}"
|
|
else:
|
|
# describe (table open + checkout validation)
|
|
body = json.dumps({"version": 1, "schema": {"fields": []}}).encode()
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(body)
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
assert isinstance(table, RemoteTable)
|
|
assert table.current_branch() is None
|
|
|
|
branch = table.branches.create("exp")
|
|
assert isinstance(branch, RemoteTable)
|
|
assert branch.current_branch() == "exp"
|
|
|
|
# list + checkout round trip; checkout also yields a branch-scoped handle
|
|
assert "exp" in table.branches.list()
|
|
checked = table.branches.checkout("exp")
|
|
assert isinstance(checked, RemoteTable)
|
|
assert checked.current_branch() == "exp"
|
|
|
|
table.branches.delete("exp")
|
|
|
|
|
|
def test_remote_table_cherry_pick_defaults_to_execute():
|
|
cherry_pick_bodies = []
|
|
diff = {
|
|
"fromBranch": "exp",
|
|
"parentVersion": 1,
|
|
"mainVersion": 2,
|
|
"branchVersion": 3,
|
|
"baseMoved": False,
|
|
"rowCountMain": 3,
|
|
"rowCountBranch": 3,
|
|
"rowSummary": {
|
|
"unchanged": 3,
|
|
"newOnBase": 0,
|
|
"newOnBranch": 0,
|
|
"staleRecompute": 0,
|
|
"inputsChanged": 0,
|
|
"deltaAvailable": False,
|
|
},
|
|
"addedColumns": [],
|
|
"removedColumns": [],
|
|
"changedColumns": [],
|
|
"addedIndexes": [],
|
|
"removedIndexes": [],
|
|
"errors": [],
|
|
}
|
|
|
|
def handler(request):
|
|
if request.path.endswith("/describe/"):
|
|
status = 200
|
|
body = {"version": 2, "schema": {"fields": []}}
|
|
else:
|
|
content_len = int(request.headers.get("Content-Length"))
|
|
request_body = json.loads(request.rfile.read(content_len))
|
|
cherry_pick_bodies.append(request_body)
|
|
dry_run = request_body["dry_run"]
|
|
status = 200 if dry_run else 409
|
|
body = {
|
|
"status": "ready" if dry_run else "failed",
|
|
"diff": diff,
|
|
"preview": {"promotedColumns": []},
|
|
}
|
|
|
|
request.send_response(status)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(body).encode())
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
branches = db.open_table("test").branches
|
|
assert branches.cherry_pick("exp")["status"] == "failed"
|
|
assert branches.cherry_pick("exp", dry_run=True)["status"] == "ready"
|
|
|
|
assert cherry_pick_bodies == [
|
|
{"from_branch": "exp", "dry_run": False},
|
|
{"from_branch": "exp", "dry_run": True},
|
|
]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_remote_open_table_branch_and_version():
|
|
async with mock_lancedb_connection_async(_branch_open_handler) as db:
|
|
# version-only (and "main" + version) time-travels the main chain
|
|
assert await db.open_table("test", version=2) is not None
|
|
main_v2 = await db.open_table("test", branch="main", version=2)
|
|
assert main_v2.current_branch() is None
|
|
|
|
# a non-main branch opens a handle scoped to that branch
|
|
exp = await db.open_table("test", branch="exp")
|
|
assert exp.current_branch() == "exp"
|
|
exp_v2 = await db.open_table("test", branch="exp", version=2)
|
|
assert exp_v2.current_branch() == "exp"
|
|
|
|
|
|
def test_remote_table_branch_survives_pickle():
|
|
# Regression: a branch-scoped handle must keep its branch across a
|
|
# pickle/fork round-trip (it used to reopen on main).
|
|
with mock_lancedb_connection(_branch_open_handler) as db:
|
|
branch = db.open_table("test", branch="exp")
|
|
assert branch.current_branch() == "exp"
|
|
restored = pickle.loads(pickle.dumps(branch))
|
|
assert restored.current_branch() == "exp"
|
|
|
|
# the pinned version is carried through as well
|
|
branch_v2 = db.open_table("test", branch="exp", version=2)
|
|
restored_v2 = pickle.loads(pickle.dumps(branch_v2))
|
|
assert restored_v2.current_branch() == "exp"
|
|
|
|
|
|
def test_table_len_sync():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(1).encode())
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
assert len(table) == 1
|
|
|
|
|
|
def test_remote_connection_serializes():
|
|
def handler(request):
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
serialized = json.loads(db.serialize())
|
|
assert isinstance(serialized["client_config"], dict)
|
|
restored = lancedb.deserialize_conn(db.serialize())
|
|
assert restored.table_names() == []
|
|
|
|
|
|
def test_remote_table_is_picklable():
|
|
def handler(request):
|
|
request.close_connection = True
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
{
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{"name": "id", "type": {"type": "int64"}, "nullable": False}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/count_rows/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"3")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
restored = pickle.loads(pickle.dumps(table))
|
|
assert restored.count_rows() == 3
|
|
|
|
|
|
def test_remote_table_open_does_not_require_picklable_client_config():
|
|
from lancedb.remote import HeaderProvider
|
|
|
|
class LocalHeaderProvider(HeaderProvider):
|
|
def get_headers(self):
|
|
return {"X-Test-Header": "present"}
|
|
|
|
def handler(request):
|
|
request.close_connection = True
|
|
assert request.headers.get("X-Test-Header") == "present"
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
elif request.path == "/v1/table/test/count_rows/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"3")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with http.server.HTTPServer(
|
|
("localhost", 0), make_mock_http_handler(handler)
|
|
) as server:
|
|
port = server.server_address[1]
|
|
handle = threading.Thread(target=server.serve_forever)
|
|
handle.start()
|
|
try:
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 0},
|
|
"timeout_config": {"connect_timeout": 2, "read_timeout": 2},
|
|
"header_provider": LocalHeaderProvider(),
|
|
},
|
|
)
|
|
table = db.open_table("test")
|
|
assert table.count_rows() == 3
|
|
with pytest.raises(ValueError, match="header_provider"):
|
|
pickle.dumps(table)
|
|
finally:
|
|
server.shutdown()
|
|
handle.join()
|
|
|
|
|
|
def test_remote_permutation_is_picklable():
|
|
from lancedb.permutation import Permutation
|
|
|
|
rows = list(range(10))
|
|
|
|
def handler(request):
|
|
request.close_connection = True
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
{
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{"name": "a", "type": {"type": "int64"}, "nullable": False}
|
|
]
|
|
},
|
|
}
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/count_rows/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(str(len(rows)).encode())
|
|
elif request.path == "/v1/table/test/query/":
|
|
content_len = int(request.headers.get("Content-Length"))
|
|
body = json.loads(request.rfile.read(content_len))
|
|
if "filter" in body:
|
|
match = re.search(
|
|
r"_rowoffset\s+in\s+\((.*?)\)", body["filter"], re.IGNORECASE
|
|
)
|
|
offsets = list(
|
|
dict.fromkeys(int(o.strip()) for o in match.group(1).split(","))
|
|
)
|
|
else:
|
|
offsets = list(range(len(rows)))
|
|
columns = body.get("columns") or ["a"]
|
|
table = pa.table(
|
|
{
|
|
column: (
|
|
[rows[offset] for offset in offsets]
|
|
if column == "a"
|
|
else offsets
|
|
)
|
|
for column in columns
|
|
}
|
|
)
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
|
|
request.end_headers()
|
|
with pa.ipc.new_file(request.wfile, schema=table.schema) as writer:
|
|
writer.write_table(table, max_chunksize=2)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
assert table.take_offsets([0, 2, 0, 4]).to_list() == [
|
|
{"a": 0},
|
|
{"a": 0},
|
|
{"a": 2},
|
|
{"a": 4},
|
|
]
|
|
|
|
permutation = Permutation.identity(table)
|
|
restored = pickle.loads(pickle.dumps(permutation))
|
|
assert restored.__getitems__([0, 2, 0, 4]) == [
|
|
{"a": 0},
|
|
{"a": 2},
|
|
{"a": 0},
|
|
{"a": 4},
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("with_schema", [True, False])
|
|
def test_create_table_embedding_functions(with_schema):
|
|
func = MockNonNormTextEmbeddingFunction.create()
|
|
config = EmbeddingFunctionConfig(
|
|
source_column="text", vector_column="vector", function=func
|
|
)
|
|
schema = pa.schema(
|
|
[pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))]
|
|
)
|
|
received = {}
|
|
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
# Echo the metadata actually sent by create_table, as the server does.
|
|
metadata = received["create"].schema.metadata or {}
|
|
send_json(
|
|
request,
|
|
{
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{
|
|
"name": "text",
|
|
"type": {"type": "string"},
|
|
"nullable": True,
|
|
},
|
|
{
|
|
"name": "vector",
|
|
"type": {
|
|
"type": "fixed_size_list",
|
|
"fields": [
|
|
{
|
|
"name": "item",
|
|
"type": {"type": "float"},
|
|
"nullable": True,
|
|
}
|
|
],
|
|
"length": 10,
|
|
},
|
|
"nullable": True,
|
|
},
|
|
],
|
|
"metadata": {
|
|
k.decode(): v.decode() for k, v in metadata.items()
|
|
},
|
|
},
|
|
},
|
|
)
|
|
elif request.path in (
|
|
"/v1/table/test/create/?mode=create",
|
|
"/v1/table/test/insert/",
|
|
):
|
|
if request.headers.get("Transfer-Encoding") == "chunked":
|
|
body = bytearray()
|
|
while True:
|
|
size = int(request.rfile.readline(), 16)
|
|
if size == 0:
|
|
request.rfile.readline()
|
|
break
|
|
body.extend(request.rfile.read(size))
|
|
request.rfile.read(2)
|
|
else:
|
|
body = request.rfile.read(int(request.headers["Content-Length"]))
|
|
operation = "create" if "/create/" in request.path else "insert"
|
|
received[operation] = pa.ipc.open_stream(body).read_all()
|
|
send_json(request, {})
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table(
|
|
"test",
|
|
schema=schema if with_schema else None,
|
|
data=None if with_schema else [{"text": "hello world"}],
|
|
embedding_functions=[config],
|
|
)
|
|
metadata = pa.schema(
|
|
[],
|
|
metadata=EmbeddingFunctionRegistry.get_instance().get_table_metadata(
|
|
[config]
|
|
),
|
|
).metadata
|
|
assert received["create"].schema.metadata == metadata
|
|
assert table.schema.metadata == metadata
|
|
assert table.schema.field("vector").type == pa.list_(pa.float32(), 10)
|
|
assert table.embedding_functions["vector"].source_column == "text"
|
|
if with_schema:
|
|
assert received["create"].num_rows == 0
|
|
else:
|
|
assert received["create"]["vector"].to_pylist() == [
|
|
func.compute_source_embeddings(["hello world"])[0].tolist()
|
|
]
|
|
|
|
table.add([{"text": "goodbye world"}])
|
|
assert received["insert"]["vector"].to_pylist() == [
|
|
func.compute_source_embeddings(["goodbye world"])[0].tolist()
|
|
]
|
|
|
|
|
|
def test_create_table_exist_ok():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create/?mode=exist_ok":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}], exist_ok=True)
|
|
assert table is not None
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}], mode="create", exist_ok=True)
|
|
assert table is not None
|
|
|
|
|
|
def test_create_table_exist_ok_with_mode_overwrite():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create/?mode=overwrite":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}], mode="overwrite", exist_ok=True)
|
|
assert table is not None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_http_error():
|
|
request_id_holder = {"request_id": None}
|
|
|
|
def handler(request):
|
|
request_id_holder["request_id"] = request.headers["x-request-id"]
|
|
|
|
request.send_response(507)
|
|
request.end_headers()
|
|
request.wfile.write(b"Internal Server Error")
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
with pytest.raises(HttpError) as exc_info:
|
|
await db.table_names()
|
|
|
|
assert exc_info.value.request_id == request_id_holder["request_id"]
|
|
assert "Internal Server Error" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_retry_error():
|
|
request_id_holder = {"request_id": None}
|
|
|
|
def handler(request):
|
|
request_id_holder["request_id"] = request.headers["x-request-id"]
|
|
|
|
request.send_response(429)
|
|
request.end_headers()
|
|
request.wfile.write(b"Try again later")
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
with pytest.raises(RetryError) as exc_info:
|
|
await db.table_names()
|
|
|
|
assert exc_info.value.request_id == request_id_holder["request_id"]
|
|
|
|
cause = exc_info.value.__cause__
|
|
assert isinstance(cause, HttpError)
|
|
assert "Try again later" in str(cause)
|
|
assert cause.request_id == request_id_holder["request_id"]
|
|
assert cause.status_code == 429
|
|
|
|
|
|
def test_table_unimplemented_functions():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
with pytest.raises(NotImplementedError):
|
|
table.to_arrow()
|
|
with pytest.raises(NotImplementedError):
|
|
table.to_pandas()
|
|
|
|
|
|
def test_table_to_pandas_not_supported():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
with pytest.raises(NotImplementedError):
|
|
table.to_pandas()
|
|
with pytest.raises(NotImplementedError):
|
|
table.to_pandas(blob_mode="bytes", split_blocks=True)
|
|
|
|
|
|
def test_table_add_in_threadpool():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/insert/":
|
|
request.send_response(200)
|
|
request.end_headers()
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
]
|
|
),
|
|
)
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
with ThreadPoolExecutor(3) as executor:
|
|
futures = []
|
|
for _ in range(10):
|
|
future = executor.submit(table.add, [{"id": 1}])
|
|
futures.append(future)
|
|
|
|
for future in futures:
|
|
future.result()
|
|
|
|
|
|
def test_table_create_indices():
|
|
# Track received index creation requests to validate name parameter
|
|
received_requests = []
|
|
|
|
def handler(request):
|
|
index_stats = dict(
|
|
index_type="IVF_PQ", num_indexed_rows=1000, num_unindexed_rows=0
|
|
)
|
|
|
|
if request.path == "/v1/table/test/create_index/":
|
|
# Capture the request body to validate name parameter
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
if content_len > 0:
|
|
body = request.rfile.read(content_len)
|
|
body_data = json.loads(body)
|
|
received_requests.append(body_data)
|
|
request.send_response(200)
|
|
request.end_headers()
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
dict(name="text", type={"type": "string"}, nullable=False),
|
|
dict(
|
|
name="vector",
|
|
type={
|
|
"type": "fixed_size_list",
|
|
"fields": [
|
|
dict(
|
|
name="item",
|
|
type={"type": "float"},
|
|
nullable=True,
|
|
)
|
|
],
|
|
"length": 2,
|
|
},
|
|
nullable=False,
|
|
),
|
|
]
|
|
),
|
|
)
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/index/list/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
dict(
|
|
indexes=[
|
|
{
|
|
"index_name": "custom_scalar_idx",
|
|
"columns": ["id"],
|
|
},
|
|
{
|
|
"index_name": "custom_fts_idx",
|
|
"columns": ["text"],
|
|
},
|
|
{
|
|
"index_name": "custom_vector_idx",
|
|
"columns": ["vector"],
|
|
},
|
|
]
|
|
)
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/index/custom_scalar_idx/stats/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(index_stats)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/index/custom_fts_idx/stats/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(index_stats)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/index/custom_vector_idx/stats/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(index_stats)
|
|
request.wfile.write(payload.encode())
|
|
elif "/drop/" in request.path:
|
|
request.send_response(200)
|
|
request.end_headers()
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
# Parameters are well-tested through local and async tests.
|
|
# This is a smoke-test.
|
|
table = db.create_table("test", [{"id": 1}])
|
|
|
|
# Test create_scalar_index with custom name (legacy method)
|
|
with pytest.warns(DeprecationWarning, match="create_scalar_index"):
|
|
table.create_scalar_index(
|
|
"id", wait_timeout=timedelta(seconds=2), name="custom_scalar_idx"
|
|
)
|
|
|
|
# Test create_fts_index with custom name (legacy method)
|
|
with pytest.warns(DeprecationWarning, match="create_fts_index"):
|
|
table.create_fts_index(
|
|
"text",
|
|
wait_timeout=timedelta(seconds=2),
|
|
block_size=256,
|
|
custom_stop_words=["cloud"],
|
|
name="custom_fts_idx",
|
|
)
|
|
|
|
# Test create_index with custom name (legacy form: vector_column_name kwarg)
|
|
with pytest.warns(DeprecationWarning, match="create_index"):
|
|
table.create_index(
|
|
vector_column_name="vector",
|
|
wait_timeout=timedelta(seconds=10),
|
|
name="custom_vector_idx",
|
|
)
|
|
|
|
# Validate that the name parameter was passed correctly in requests
|
|
assert len(received_requests) == 3
|
|
|
|
# Check scalar index request has custom name
|
|
scalar_req = received_requests[0]
|
|
assert "name" in scalar_req
|
|
assert scalar_req["name"] == "custom_scalar_idx"
|
|
assert scalar_req["replace"] is False
|
|
|
|
# Check FTS index request has custom name
|
|
fts_req = received_requests[1]
|
|
assert "name" in fts_req
|
|
assert fts_req["name"] == "custom_fts_idx"
|
|
assert fts_req["replace"] is False
|
|
assert fts_req["block_size"] == 256
|
|
assert fts_req["custom_stop_words"] == ["cloud"]
|
|
|
|
# Check vector index request has custom name
|
|
vector_req = received_requests[2]
|
|
assert "name" in vector_req
|
|
assert vector_req["name"] == "custom_vector_idx"
|
|
assert "replace" not in vector_req
|
|
|
|
table.wait_for_index(["custom_scalar_idx"], timedelta(seconds=2))
|
|
table.wait_for_index(
|
|
["custom_fts_idx", "custom_vector_idx"], timedelta(seconds=2)
|
|
)
|
|
table.drop_index("custom_vector_idx")
|
|
table.drop_index("custom_scalar_idx")
|
|
table.drop_index("custom_fts_idx")
|
|
|
|
|
|
@pytest.mark.parametrize("legacy", [False, True])
|
|
@pytest.mark.parametrize(
|
|
("base_tokenizer", "options", "expected_filters"),
|
|
[
|
|
("code", {}, (False, False)),
|
|
("code", {"stem": None, "remove_stop_words": None}, (False, False)),
|
|
("code", {"stem": True}, (True, False)),
|
|
("code", {"remove_stop_words": True}, (False, True)),
|
|
("code", {"stem": True, "remove_stop_words": True}, (True, True)),
|
|
("simple", {}, (True, True)),
|
|
("simple", {"stem": False, "remove_stop_words": False}, (False, False)),
|
|
],
|
|
)
|
|
def test_remote_fts_tokenizer_filter_defaults(
|
|
legacy, base_tokenizer, options, expected_filters
|
|
):
|
|
from lancedb.index import FTS
|
|
|
|
received_requests = []
|
|
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create_index/":
|
|
content_len = int(request.headers["Content-Length"])
|
|
received_requests.append(json.loads(request.rfile.read(content_len)))
|
|
request.send_response(200)
|
|
request.end_headers()
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{
|
|
"name": "text",
|
|
"type": {"type": "string"},
|
|
"nullable": False,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
).encode()
|
|
)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"text": "Running in cafes"}])
|
|
if legacy:
|
|
with pytest.warns(DeprecationWarning, match="create_fts_index"):
|
|
table.create_fts_index("text", base_tokenizer=base_tokenizer, **options)
|
|
else:
|
|
table.create_index(
|
|
"text", config=FTS(base_tokenizer=base_tokenizer, **options)
|
|
)
|
|
|
|
assert len(received_requests) == 1
|
|
params = received_requests[0]
|
|
assert params["base_tokenizer"] == base_tokenizer
|
|
assert (params["stem"], params["remove_stop_words"]) == expected_filters
|
|
|
|
|
|
def test_remote_create_index_async_returns_job():
|
|
from lancedb.index import BTree
|
|
|
|
describe_calls = []
|
|
|
|
def handler(request):
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
if request.path == "/v1/table/test/create_index/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"job_id": "job-1"}')
|
|
elif request.path == "/v1/jobs/describe":
|
|
assert json.loads(body)["job_id"] == "job-1"
|
|
describe_calls.append(1)
|
|
state = "IN_PROGRESS" if len(describe_calls) == 1 else "DONE"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(dict(job_id="job-1", job_state=state)).encode()
|
|
)
|
|
elif request.path == "/v1/jobs/cancel":
|
|
assert json.loads(body)["job_id"] == "job-1"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
]
|
|
),
|
|
)
|
|
).encode()
|
|
)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
job = table.create_index_async("id", config=BTree())
|
|
assert job.id == "job-1"
|
|
assert job.wait(timeout=timedelta(seconds=30)) is None
|
|
assert len(describe_calls) == 2
|
|
job.cancel()
|
|
|
|
|
|
def test_remote_refresh_async_returns_typed_terminal_result():
|
|
terminal_result = {
|
|
"rows_assigned": 12,
|
|
"rows_failed": 0,
|
|
"rows_remaining": 0,
|
|
"source_version": 7,
|
|
"published_version": 8,
|
|
}
|
|
|
|
def handler(request):
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
if request.path == "/v1/table/test/backfill_column":
|
|
assert json.loads(body)["column"] == "derived"
|
|
request.send_response(202)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"job_id": "refresh-1"}')
|
|
elif request.path == "/v1/jobs/describe":
|
|
assert json.loads(body)["job_id"] == "refresh-1"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
{
|
|
"job_id": "refresh-1",
|
|
"job_type": "function_refresh",
|
|
"job_state": "DONE",
|
|
"result": terminal_result,
|
|
}
|
|
).encode()
|
|
)
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
{
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{
|
|
"name": "id",
|
|
"type": {"type": "int64"},
|
|
"nullable": False,
|
|
}
|
|
]
|
|
},
|
|
}
|
|
).encode()
|
|
)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
job = table.refresh_column_async("derived")
|
|
assert job.id == "refresh-1"
|
|
result = job.wait(timeout=timedelta(seconds=30))
|
|
|
|
assert isinstance(result, lancedb.RefreshColumnResult)
|
|
assert result.model_dump() == terminal_result
|
|
assert result.rows_filled == 12
|
|
assert result.version == 8
|
|
|
|
|
|
def test_remote_function_errors_lists_the_rows_a_refresh_skipped():
|
|
listing = {
|
|
"records": [
|
|
{
|
|
"job_id": "j-7",
|
|
"fragment_id": 3,
|
|
"row_offset": 9,
|
|
"column": "embedding",
|
|
"function": "embed",
|
|
"function_version": "2",
|
|
"table_version": 11,
|
|
"error_type": "ValueError",
|
|
"error_message": "bad input 'x'",
|
|
"created_at_millis": 1700000000000,
|
|
}
|
|
],
|
|
"fragments": [
|
|
{
|
|
"job_id": "j-7",
|
|
"fragment_id": 4,
|
|
"rows_skipped": 500,
|
|
"rows_recorded": 100,
|
|
}
|
|
],
|
|
"truncated": True,
|
|
}
|
|
bodies = []
|
|
|
|
def handler(request):
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
if request.path == "/v1/table/test/errors":
|
|
bodies.append(json.loads(body))
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(listing).encode())
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps({"version": 1, "schema": {"fields": []}}).encode()
|
|
)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
errors = table.function_errors(job_id="j-7", column="embedding", limit=2)
|
|
everything = table.function_errors()
|
|
|
|
assert bodies == [{"job_id": "j-7", "column": "embedding", "limit": 2}, {}]
|
|
assert errors.truncated is True
|
|
assert [r.error_message for r in errors.records] == ["bad input 'x'"]
|
|
assert errors.records[0].row_offset == 9
|
|
assert errors.records[0].function_version == "2"
|
|
assert (errors.fragments[0].rows_skipped, errors.fragments[0].rows_recorded) == (
|
|
500,
|
|
100,
|
|
)
|
|
assert everything.truncated is True
|
|
|
|
|
|
def test_remote_job_wait_raises_on_failure():
|
|
from lancedb.exceptions import JobFailedError
|
|
from lancedb.index import BTree
|
|
|
|
def handler(request):
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
if request.path == "/v1/table/test/create_index/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"job_id": "job-2"}')
|
|
elif request.path == "/v1/jobs/describe":
|
|
assert json.loads(body)["job_id"] == "job-2"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(dict(job_id="job-2", job_state="FAILED")).encode()
|
|
)
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
]
|
|
),
|
|
)
|
|
).encode()
|
|
)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
job = table.create_index_async("id", config=BTree())
|
|
with pytest.raises(JobFailedError, match="job-2"):
|
|
job.wait()
|
|
|
|
|
|
@pytest.mark.parametrize("server_version", ["0.2.0", "0.6.0"])
|
|
def test_remote_json_fts_config(server_version):
|
|
from lancedb.index import FTS
|
|
|
|
received_requests = []
|
|
|
|
def handler(request):
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.send_header("phalanx-version", server_version)
|
|
request.end_headers()
|
|
if request.path == "/v1/table/test/describe/":
|
|
fields = [
|
|
{
|
|
"name": "doc",
|
|
"type": {"type": "large_binary"},
|
|
"nullable": True,
|
|
"metadata": {"ARROW:extension:name": "lance.json"},
|
|
},
|
|
{
|
|
"name": "raw",
|
|
"type": {"type": "large_binary"},
|
|
"nullable": True,
|
|
},
|
|
]
|
|
request.wfile.write(
|
|
json.dumps({"version": 1, "schema": {"fields": fields}}).encode()
|
|
)
|
|
elif request.path == "/v1/table/test/create_index/":
|
|
received_requests.append(read_json_body(request))
|
|
request.wfile.write(b"{}")
|
|
else:
|
|
raise AssertionError(f"Unexpected request: {request.path}")
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
table.create_index(
|
|
"doc",
|
|
config=FTS(
|
|
with_position=True,
|
|
base_tokenizer="whitespace",
|
|
stem=False,
|
|
remove_stop_words=False,
|
|
lower_case=False,
|
|
block_size=256,
|
|
),
|
|
name="doc_fts",
|
|
replace=False,
|
|
)
|
|
with pytest.raises(ValueError, match="FTS.*lance.json.*raw binary"):
|
|
table.create_index("raw", config=FTS())
|
|
|
|
assert len(received_requests) == 1
|
|
request = received_requests[0]
|
|
assert request["column"] == "doc"
|
|
assert request["index_type"] == "FTS"
|
|
assert request["name"] == "doc_fts"
|
|
assert request["replace"] is False
|
|
assert request["with_position"] is True
|
|
assert request["base_tokenizer"] == "whitespace"
|
|
assert request["stem"] is False
|
|
assert request["remove_stop_words"] is False
|
|
assert request["lower_case"] is False
|
|
assert request["block_size"] == 256
|
|
|
|
|
|
def test_remote_create_index_new_api(caplog):
|
|
received_requests = []
|
|
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create_index/":
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
received_requests.append(json.loads(body) if body else {})
|
|
request.send_response(200)
|
|
request.end_headers()
|
|
elif request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
dict(
|
|
name="category",
|
|
type={"type": "string"},
|
|
nullable=False,
|
|
),
|
|
dict(
|
|
name="text", type={"type": "string"}, nullable=False
|
|
),
|
|
dict(
|
|
name="vector",
|
|
type={
|
|
"type": "fixed_size_list",
|
|
"fields": [
|
|
dict(
|
|
name="item",
|
|
type={"type": "float"},
|
|
nullable=True,
|
|
)
|
|
],
|
|
"length": 2,
|
|
},
|
|
nullable=False,
|
|
),
|
|
]
|
|
),
|
|
)
|
|
).encode()
|
|
)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
from lancedb.index import BTree, FTS, IvfPq, IvfRq
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}], embedding_functions=[])
|
|
assert "embedding_functions is not yet supported" not in caplog.text
|
|
|
|
# New API: column-first, config= kwarg. Should NOT emit DeprecationWarning.
|
|
import warnings as _warnings
|
|
|
|
with _warnings.catch_warnings():
|
|
_warnings.simplefilter("error", DeprecationWarning)
|
|
table.create_index("vector", config=IvfPq(distance_type="l2"))
|
|
table.create_index("category", config=BTree())
|
|
table.create_index("text", config=FTS(block_size=256))
|
|
# IvfRq via new API
|
|
table.create_index("vector", config=IvfRq(distance_type="l2"))
|
|
table.create_index(
|
|
"vector", config=IvfPq(distance_type="l2"), replace=False
|
|
)
|
|
|
|
# Legacy index_type="IVF_RQ" routes to IvfRq config under the hood.
|
|
with pytest.warns(DeprecationWarning, match="create_index"):
|
|
table.create_index(
|
|
vector_column_name="vector",
|
|
index_type="IVF_RQ",
|
|
num_partitions=8,
|
|
replace=False,
|
|
accelerator="cuda",
|
|
)
|
|
|
|
assert len(received_requests) == 6
|
|
assert [req["column"] for req in received_requests] == [
|
|
"vector",
|
|
"category",
|
|
"text",
|
|
"vector",
|
|
"vector",
|
|
"vector",
|
|
]
|
|
assert received_requests[2]["block_size"] == 256
|
|
assert received_requests[4]["replace"] is False
|
|
assert received_requests[5]["replace"] is False
|
|
assert "replace is not supported" not in caplog.text
|
|
assert caplog.messages == [
|
|
"GPU accelerator is not supported for remote tables and will be ignored."
|
|
]
|
|
|
|
|
|
def remote_unsupported_handler(request):
|
|
assert request.path == "/v1/table/test/describe/"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
dict(
|
|
name="vector",
|
|
type={
|
|
"type": "fixed_size_list",
|
|
"fields": [
|
|
dict(
|
|
name="item",
|
|
type={"type": "float"},
|
|
nullable=True,
|
|
)
|
|
],
|
|
"length": 2,
|
|
},
|
|
nullable=False,
|
|
),
|
|
]
|
|
),
|
|
)
|
|
).encode()
|
|
)
|
|
|
|
|
|
def remote_unsupported_operations():
|
|
from lancedb.index import HnswPq
|
|
|
|
return [
|
|
("uses_v2_manifest_paths", (), {}, "uses_v2_manifest_paths"),
|
|
("migrate_v2_manifest_paths", (), {}, "migrate_manifest_paths_v2"),
|
|
("set_unenforced_primary_key", (["id"],), {}, "set_unenforced_primary_key"),
|
|
("fetch_blob_ranges", ("image", [(1, 0, 1)]), {}, "fetch_blob_ranges"),
|
|
("optimize", (), {}, "optimize"),
|
|
("create_index", ("vector",), {"config": HnswPq()}, "IVF_HNSW_PQ"),
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"method,args,kwargs,operation", remote_unsupported_operations()
|
|
)
|
|
def test_remote_unsupported_operations_sync(method, args, kwargs, operation):
|
|
with mock_lancedb_connection(remote_unsupported_handler) as db:
|
|
table = db.open_table("test")
|
|
with pytest.raises(NotImplementedError) as error:
|
|
getattr(table, method)(*args, **kwargs)
|
|
expected = f"LanceDBError: not supported: {operation} is not supported "
|
|
expected += "for remote tables."
|
|
if operation == "IVF_HNSW_PQ":
|
|
expected += " Please use IVF_HNSW_SQ instead."
|
|
assert str(error.value) == expected
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize(
|
|
"method,args,kwargs,operation", remote_unsupported_operations()
|
|
)
|
|
async def test_remote_unsupported_operations_async(method, args, kwargs, operation):
|
|
async with mock_lancedb_connection_async(remote_unsupported_handler) as db:
|
|
table = await db.open_table("test")
|
|
if method == "migrate_v2_manifest_paths":
|
|
method = "migrate_manifest_paths_v2"
|
|
with pytest.raises(NotImplementedError) as error:
|
|
await getattr(table, method)(*args, **kwargs)
|
|
expected = f"LanceDBError: not supported: {operation} is not supported "
|
|
expected += "for remote tables."
|
|
if operation == "IVF_HNSW_PQ":
|
|
expected += " Please use IVF_HNSW_SQ instead."
|
|
assert str(error.value) == expected
|
|
|
|
|
|
def test_remote_unsupported_legacy_hnsw_pq():
|
|
with mock_lancedb_connection(remote_unsupported_handler) as db:
|
|
table = db.open_table("test")
|
|
with pytest.warns(DeprecationWarning, match="create_index"):
|
|
with pytest.raises(
|
|
NotImplementedError,
|
|
match="IVF_HNSW_PQ is not supported for remote tables. "
|
|
"Please use IVF_HNSW_SQ instead.",
|
|
):
|
|
table.create_index(
|
|
vector_column_name="vector", index_type="IVF_HNSW_PQ"
|
|
)
|
|
|
|
|
|
def test_remote_python_only_messages(caplog):
|
|
with mock_lancedb_connection(remote_unsupported_handler) as db:
|
|
with caplog.at_level("INFO"):
|
|
table = db.open_table("test", storage_options={}, index_cache_size=1)
|
|
assert "storage_options is ignored for remote tables" in caplog.text
|
|
assert "index_cache_size is ignored for remote tables" in caplog.text
|
|
for method in ["to_arrow", "to_pandas"]:
|
|
with pytest.raises(NotImplementedError) as error:
|
|
getattr(table, method)()
|
|
assert str(error.value) == f"{method}() is not supported for remote tables."
|
|
for method in ["compact_files", "cleanup_old_versions"]:
|
|
with pytest.warns(UserWarning) as warning:
|
|
getattr(table, method)()
|
|
assert (
|
|
str(warning[0].message) == f"{method}() is a no-op for remote tables."
|
|
)
|
|
|
|
|
|
def test_remote_embedding_config_does_not_warn_or_drop_metadata(caplog):
|
|
received_tables = []
|
|
|
|
def handler(request):
|
|
assert request.path == "/v1/table/test/create/?mode=create"
|
|
if request.headers.get("Transfer-Encoding") == "chunked":
|
|
body = bytearray()
|
|
while size := int(request.rfile.readline().split(b";")[0], 16):
|
|
body.extend(request.rfile.read(size))
|
|
assert request.rfile.read(2) == b"\r\n"
|
|
assert request.rfile.read(2) == b"\r\n"
|
|
else:
|
|
body = request.rfile.read(int(request.headers["Content-Length"]))
|
|
received_tables.append(pa.ipc.open_stream(body).read_all())
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
|
|
config = EmbeddingFunctionConfig(
|
|
source_column="text",
|
|
vector_column="vector",
|
|
function=MockNonNormTextEmbeddingFunction.create(),
|
|
)
|
|
schema = pa.schema(
|
|
[pa.field("text", pa.string()), pa.field("vector", pa.list_(pa.float32(), 10))]
|
|
)
|
|
with mock_lancedb_connection(handler) as db:
|
|
db.create_table("test", schema=schema, embedding_functions=[config])
|
|
assert caplog.messages == []
|
|
assert len(received_tables) == 1
|
|
metadata = EmbeddingFunctionRegistry.get_instance().get_table_metadata([config])
|
|
assert (
|
|
received_tables[0].schema.metadata == pa.schema([], metadata=metadata).metadata
|
|
)
|
|
|
|
|
|
def test_table_wait_for_index_timeout():
|
|
def handler(request):
|
|
index_stats = dict(
|
|
index_type="BTREE", num_indexed_rows=1000, num_unindexed_rows=1
|
|
)
|
|
|
|
if request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
dict(
|
|
version=1,
|
|
schema=dict(
|
|
fields=[
|
|
dict(name="id", type={"type": "int64"}, nullable=False),
|
|
]
|
|
),
|
|
)
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/index/list/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(
|
|
dict(
|
|
indexes=[
|
|
{
|
|
"index_name": "id_idx",
|
|
"columns": ["id"],
|
|
},
|
|
]
|
|
)
|
|
)
|
|
request.wfile.write(payload.encode())
|
|
elif request.path == "/v1/table/test/index/id_idx/stats/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(index_stats)
|
|
print(f"{index_stats=}")
|
|
request.wfile.write(payload.encode())
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
with pytest.raises(
|
|
RuntimeError,
|
|
match=re.escape(
|
|
'Timeout error: timed out waiting for indices: ["id_idx"] after 1s'
|
|
),
|
|
):
|
|
table.wait_for_index(["id_idx"], timedelta(seconds=1))
|
|
|
|
|
|
def test_stats():
|
|
stats = {
|
|
"total_bytes": 38,
|
|
"num_rows": 2,
|
|
"num_indices": 0,
|
|
"fragment_stats": {
|
|
"num_fragments": 1,
|
|
"num_small_fragments": 1,
|
|
"lengths": {
|
|
"min": 2,
|
|
"max": 2,
|
|
"mean": 2,
|
|
"p25": 2,
|
|
"p50": 2,
|
|
"p75": 2,
|
|
"p99": 2,
|
|
},
|
|
},
|
|
}
|
|
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/stats/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = json.dumps(stats)
|
|
request.wfile.write(payload.encode())
|
|
else:
|
|
print(request.path)
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.create_table("test", [{"id": 1}])
|
|
res = table.stats()
|
|
print(f"{res=}")
|
|
# The server does not report num_deleted_rows, and that unsupported
|
|
# state is preserved as None.
|
|
assert res == {**stats, "num_deleted_rows": None}
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def lsm_test_table(lsm_handler):
|
|
"""A remote table whose LSM routes are served by ``lsm_handler``.
|
|
|
|
``lsm_handler(request, route)`` is called for ``/v1/table/test/<route>/``
|
|
where route is one of flush_memwal, compact_memwal, get_memwal_stats, and is
|
|
responsible for writing the response.
|
|
"""
|
|
routes = ("flush_memwal", "compact_memwal", "get_memwal_stats")
|
|
|
|
def handler(request):
|
|
match = re.fullmatch(r"/v1/table/test/(\w+)/", request.path)
|
|
route = match.group(1) if match else None
|
|
if route in routes:
|
|
lsm_handler(request, route)
|
|
elif route == "describe":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
yield db.open_table("test")
|
|
|
|
|
|
def read_json_body(request):
|
|
content_len = int(request.headers.get("Content-Length"))
|
|
return json.loads(request.rfile.read(content_len))
|
|
|
|
|
|
def send_json(request, payload, status=200):
|
|
request.send_response(status)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(payload).encode())
|
|
|
|
|
|
def test_get_memwal_stats_sync():
|
|
"""The sync wrapper round-trips the server payload into a dict."""
|
|
table_shard = {
|
|
"shard_id": "b0",
|
|
"status": "Active",
|
|
"writer_epoch": 3,
|
|
"manifest_version": 12,
|
|
"current_generation": 6,
|
|
"replay_after_wal_entry_position": 40,
|
|
"wal_entry_position_last_seen": 42,
|
|
"sstables": [{"generation": 5, "bytes": 1024, "rows": 7}],
|
|
"compacting": False,
|
|
"memtables": [
|
|
{
|
|
"generation": 6,
|
|
"rows": 2,
|
|
"bytes": 64,
|
|
"batches": 1,
|
|
"indexes": ["vec_idx"],
|
|
}
|
|
],
|
|
}
|
|
seen_bodies = []
|
|
|
|
def lsm_handler(request, route):
|
|
assert route == "get_memwal_stats"
|
|
seen_bodies.append(read_json_body(request))
|
|
send_json(request, {"memwal_stats": {"table_shards": [table_shard]}})
|
|
|
|
with lsm_test_table(lsm_handler) as table:
|
|
assert table.get_memwal_stats() == {"table_shards": [table_shard]}
|
|
# Off by default, and forwarded when asked for.
|
|
assert seen_bodies == [{"include_sstable_rows": False}]
|
|
table.get_memwal_stats(include_sstable_rows=True)
|
|
assert seen_bodies[-1] == {"include_sstable_rows": True}
|
|
|
|
|
|
def test_get_memwal_stats_sync_returns_none_when_memwal_disabled():
|
|
"""A null envelope means the MemWAL write path is not enabled, not an error."""
|
|
|
|
def lsm_handler(request, route):
|
|
send_json(request, {"memwal_stats": None})
|
|
|
|
with lsm_test_table(lsm_handler) as table:
|
|
assert table.get_memwal_stats() is None
|
|
|
|
|
|
def test_flush_and_compact_memwal_sync():
|
|
"""Both are one-shot POSTs answered 202 with no body."""
|
|
called = []
|
|
|
|
def lsm_handler(request, route):
|
|
called.append(route)
|
|
request.send_response(202)
|
|
request.end_headers()
|
|
|
|
with lsm_test_table(lsm_handler) as table:
|
|
assert table.flush_memwal() is None
|
|
assert table.compact_memwal() is None
|
|
assert called == ["flush_memwal", "compact_memwal"]
|
|
|
|
|
|
def test_checkpoint_memwal_sync():
|
|
"""Freeze, read the watermark, and return once no SSTables remain.
|
|
|
|
The convergence loop itself is covered in Rust; this pins the sync
|
|
binding to the endpoints it drives.
|
|
"""
|
|
called = []
|
|
|
|
def lsm_handler(request, route):
|
|
called.append(route)
|
|
if route == "get_memwal_stats":
|
|
# An empty SSTable tier yields no target watermark, so the loop is done
|
|
# after the freeze without ever polling compaction.
|
|
send_json(request, {"memwal_stats": {"table_shards": []}})
|
|
else:
|
|
request.send_response(202)
|
|
request.end_headers()
|
|
|
|
with lsm_test_table(lsm_handler) as table:
|
|
assert table.checkpoint_memwal() is None
|
|
assert called == ["flush_memwal", "get_memwal_stats"]
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def query_test_table(
|
|
query_handler,
|
|
*,
|
|
server_version=Version("0.1.0"),
|
|
vector_columns=None,
|
|
embedding_functions=None,
|
|
):
|
|
if vector_columns is None:
|
|
vector_columns = {"vector": 3}
|
|
schema = {
|
|
"fields": [
|
|
{"name": "text", "type": {"type": "string"}, "nullable": False},
|
|
*[
|
|
{
|
|
"name": name,
|
|
"type": {
|
|
"type": "fixed_size_list",
|
|
"fields": [
|
|
{
|
|
"name": "item",
|
|
"type": {"type": "float"},
|
|
"nullable": True,
|
|
}
|
|
],
|
|
"length": dim,
|
|
},
|
|
"nullable": False,
|
|
}
|
|
for name, dim in vector_columns.items()
|
|
],
|
|
]
|
|
}
|
|
if embedding_functions:
|
|
metadata = EmbeddingFunctionRegistry.get_instance().get_table_metadata(
|
|
embedding_functions
|
|
)
|
|
schema["metadata"] = {key: value.decode() for key, value in metadata.items()}
|
|
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.send_header("phalanx-version", str(server_version))
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps({"version": 1, "schema": schema}).encode())
|
|
elif request.path == "/v1/table/test/query/":
|
|
content_len = int(request.headers.get("Content-Length"))
|
|
body = request.rfile.read(content_len)
|
|
body = json.loads(body)
|
|
|
|
data = query_handler(body)
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
|
|
request.end_headers()
|
|
|
|
with pa.ipc.new_file(request.wfile, schema=data.schema) as f:
|
|
f.write_table(data)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
assert repr(db) == "RemoteConnect(name=dev)"
|
|
table = db.open_table("test")
|
|
assert repr(table) == "RemoteTable(dev.test)"
|
|
yield table
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"query,search_kwargs,expected_column",
|
|
[
|
|
pytest.param([0.5] * 8, {}, "vector", id="vector-8"),
|
|
pytest.param([0.5] * 4, {}, "vec2", id="vector-4"),
|
|
pytest.param(np.array([0.5] * 4), {}, "vec2", id="numpy-vector"),
|
|
pytest.param([[0.5] * 4, [0.2] * 4], {}, "vec2", id="batch-vectors"),
|
|
pytest.param(
|
|
[0.5] * 4, {"vector_column_name": "vec2"}, "vec2", id="explicit-column"
|
|
),
|
|
pytest.param(None, {}, None, id="scan"),
|
|
pytest.param("hello", {"query_type": "fts"}, None, id="fts"),
|
|
pytest.param(
|
|
"hello", {"query_type": "fts", "fts_columns": "text"}, None, id="fts-column"
|
|
),
|
|
pytest.param(MatchQuery("hello", "text"), {}, None, id="structured-fts"),
|
|
],
|
|
)
|
|
def test_query_object_sync_matches_local(mem_db, query, search_kwargs, expected_column):
|
|
vector_columns = {"vector": 8, "vec2": 4}
|
|
schema = pa.schema(
|
|
[
|
|
pa.field(name, pa.list_(pa.float32(), dim))
|
|
for name, dim in vector_columns.items()
|
|
]
|
|
)
|
|
local = mem_db.create_table("test", schema=schema)
|
|
local_query = local.search(query, **search_kwargs).limit(2).to_query_object()
|
|
assert local_query.vector_column == expected_column
|
|
assert local_query.fast_search is None
|
|
|
|
with query_test_table(None, vector_columns=vector_columns) as remote:
|
|
remote_query = remote.search(query, **search_kwargs).limit(2).to_query_object()
|
|
assert remote_query == local_query
|
|
|
|
|
|
@pytest.mark.parametrize("fast_search", [None, False, True])
|
|
@pytest.mark.parametrize("query,query_type", [([1, 2, 3], "vector"), ("hello", "fts")])
|
|
def test_query_object_sync_preserves_fast_search(fast_search, query, query_type):
|
|
vector_columns = {"vector": 3} if query_type == "vector" else {}
|
|
with query_test_table(None, vector_columns=vector_columns) as table:
|
|
query_obj = table.search(
|
|
query, query_type=query_type, fast_search=fast_search
|
|
).to_query_object()
|
|
assert query_obj.fast_search is fast_search
|
|
|
|
|
|
def test_query_sync_rejects_ambiguous_vector_column(mem_db):
|
|
vector_columns = {"vector": 4, "vec2": 4}
|
|
schema = pa.schema(
|
|
[
|
|
pa.field(name, pa.list_(pa.float32(), dim))
|
|
for name, dim in vector_columns.items()
|
|
]
|
|
)
|
|
local = mem_db.create_table("test", schema=schema)
|
|
with pytest.raises(ValueError, match="Candidates:.*vector.*vec2") as local_error:
|
|
local.search([0.5] * 4)
|
|
|
|
with query_test_table(None, vector_columns=vector_columns) as remote:
|
|
with pytest.raises(ValueError) as remote_error:
|
|
remote.search([0.5] * 4)
|
|
assert str(remote_error.value) == str(local_error.value)
|
|
|
|
|
|
@pytest.mark.parametrize("vector_columns", [{}, {"v1": 4, "v2": 8}])
|
|
@pytest.mark.parametrize("query_type", ["auto", "fts"])
|
|
def test_query_sync_fts_without_embeddings(vector_columns, query_type):
|
|
seen = []
|
|
|
|
def handler(body):
|
|
seen.append(body)
|
|
assert body["full_text_query"] == {"query": "hello", "columns": []}
|
|
assert body["vector"] == []
|
|
assert "vector_column" not in body
|
|
return pa.table({"id": [1]})
|
|
|
|
with query_test_table(handler, vector_columns=vector_columns) as table:
|
|
assert table.search("hello", query_type=query_type).to_list() == [{"id": 1}]
|
|
assert len(seen) == 1
|
|
|
|
|
|
def test_query_sync_auto_with_embedding_function():
|
|
def handler(body):
|
|
assert body["vector_column"] == "vector"
|
|
assert body["vector"] == [0.0] * 10
|
|
assert "full_text_query" not in body
|
|
return pa.table({"id": [1]})
|
|
|
|
config = EmbeddingFunctionConfig(
|
|
source_column="text",
|
|
vector_column="vector",
|
|
function=MockTextEmbeddingFunction.create(),
|
|
)
|
|
with query_test_table(
|
|
handler, vector_columns={"vector": 10}, embedding_functions=[config]
|
|
) as table:
|
|
query = table.search("hello")
|
|
assert query.to_query_object().vector_column == "vector"
|
|
assert query.to_list() == [{"id": 1}]
|
|
|
|
|
|
def test_head():
|
|
def handler(body):
|
|
assert body == {
|
|
"k": 5,
|
|
"prefilter": True,
|
|
"vector": [],
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3]})
|
|
|
|
with query_test_table(handler) as table:
|
|
data = table.head(5)
|
|
assert data == pa.table({"id": [1, 2, 3]})
|
|
|
|
|
|
def test_query_sync_minimal():
|
|
def handler(body):
|
|
assert body == {
|
|
"k": 10,
|
|
"prefilter": True,
|
|
"refine_factor": None,
|
|
"lower_bound": None,
|
|
"upper_bound": None,
|
|
"ef": None,
|
|
"vector": [1.0, 2.0, 3.0],
|
|
"vector_column": "vector",
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3]})
|
|
|
|
with query_test_table(handler) as table:
|
|
data = table.search([1, 2, 3]).to_list()
|
|
expected = [{"id": 1}, {"id": 2}, {"id": 3}]
|
|
assert data == expected
|
|
|
|
|
|
@pytest.mark.parametrize("query_type", ["auto", "vector", "hybrid"])
|
|
@pytest.mark.parametrize("vector_column_name", [None, "embedding"])
|
|
def test_query_sync_text_embedding(query_type, vector_column_name):
|
|
embedding_func = MockTextEmbeddingFunction.create()
|
|
config = EmbeddingFunctionConfig(
|
|
source_column="text", vector_column="embedding", function=embedding_func
|
|
)
|
|
query = "quick brown fox"
|
|
expected_vector = embedding_func.compute_query_embeddings(query)[0]
|
|
queries = []
|
|
|
|
def handler(body):
|
|
queries.append(body)
|
|
if "full_text_query" in body:
|
|
assert query_type == "hybrid"
|
|
assert body["full_text_query"]["query"] == query
|
|
return pa.table({"id": [1], "_rowid": [1], "_score": [1.0]})
|
|
assert body["vector_column"] == "embedding"
|
|
assert body["vector"] == pytest.approx(expected_vector)
|
|
return pa.table({"id": [1], "_rowid": [1], "_distance": [0.0]})
|
|
|
|
with query_test_table(
|
|
handler,
|
|
vector_columns={
|
|
"embedding": embedding_func.ndims(),
|
|
**({"other": embedding_func.ndims()} if vector_column_name else {}),
|
|
},
|
|
embedding_functions=[config],
|
|
) as table:
|
|
assert list(table.embedding_functions) == ["embedding"]
|
|
builder = table.search(
|
|
query, query_type=query_type, vector_column_name=vector_column_name
|
|
)
|
|
if query_type != "hybrid":
|
|
assert isinstance(builder, LanceVectorQueryBuilder)
|
|
results = builder.to_list()
|
|
assert results[0]["id"] == 1
|
|
if query_type != "hybrid":
|
|
assert results[0]["_distance"] == 0.0
|
|
assert len(queries) == (2 if query_type == "hybrid" else 1)
|
|
|
|
|
|
@pytest.mark.parametrize("query_type", ["auto", "vector", "hybrid"])
|
|
def test_query_sync_ambiguous_vector_columns(query_type):
|
|
config = EmbeddingFunctionConfig(
|
|
source_column="text",
|
|
vector_column="v1",
|
|
function=MockTextEmbeddingFunction.create(),
|
|
)
|
|
queries = []
|
|
|
|
def handler(body):
|
|
queries.append(body)
|
|
return pa.table({"id": [1]})
|
|
|
|
with query_test_table(
|
|
handler,
|
|
vector_columns={"v1": 10, "v2": 10},
|
|
embedding_functions=[config],
|
|
) as table:
|
|
with pytest.raises(ValueError, match=r"Candidates: \['v1', 'v2'\]"):
|
|
table.search("puppy", query_type=query_type).to_list()
|
|
assert queries == []
|
|
|
|
|
|
@pytest.mark.parametrize("vector_column, dim", [("v1", 3), ("v2", 4)])
|
|
def test_query_sync_infers_vector_column_by_dimension(vector_column, dim):
|
|
def handler(body):
|
|
assert body["vector_column"] == vector_column
|
|
assert body["vector"] == [1.0] * dim
|
|
return pa.table({"id": [1]})
|
|
|
|
with query_test_table(handler, vector_columns={"v1": 3, "v2": 4}) as table:
|
|
assert table.search([1.0] * dim).to_list() == [{"id": 1}]
|
|
|
|
|
|
@pytest.mark.parametrize("vector_columns", [{}, {"v1": 3, "v2": 3}])
|
|
def test_query_sync_empty_query(vector_columns):
|
|
def handler(body):
|
|
assert body == {
|
|
"k": 10,
|
|
"filter": "true",
|
|
"vector": [],
|
|
"columns": ["id"],
|
|
"prefilter": True,
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3]})
|
|
|
|
with query_test_table(handler, vector_columns=vector_columns) as table:
|
|
data = table.search(None).where("true").select(["id"]).limit(10).to_list()
|
|
expected = [{"id": 1}, {"id": 2}, {"id": 3}]
|
|
assert data == expected
|
|
|
|
|
|
@pytest.mark.parametrize("query", [[], np.array([], dtype=np.float32)])
|
|
@pytest.mark.parametrize("vector_column_name", [None, "vector"])
|
|
@pytest.mark.parametrize("query_type", ["auto", "vector"])
|
|
def test_query_sync_empty_vector(query, vector_column_name, query_type):
|
|
def handler(body):
|
|
pytest.fail("An empty query vector must be rejected before sending a query")
|
|
|
|
with query_test_table(handler) as table:
|
|
with pytest.raises(ValueError, match="^Query vector must not be empty$"):
|
|
table.search(
|
|
query, vector_column_name=vector_column_name, query_type=query_type
|
|
).limit(3).to_arrow()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("search_kwargs", [{}, {"query": None}])
|
|
async def test_async_search_without_query(search_kwargs):
|
|
expected = pa.table({"id": [7, 8, 9]})
|
|
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
elif request.path == "/v1/table/test/query/":
|
|
body = json.loads(
|
|
request.rfile.read(int(request.headers["Content-Length"]))
|
|
)
|
|
assert body == {
|
|
"k": 3,
|
|
"filter": "id >= 7",
|
|
"vector": [],
|
|
"columns": ["id"],
|
|
"prefilter": True,
|
|
"version": None,
|
|
}
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
|
|
request.end_headers()
|
|
with pa.ipc.new_file(request.wfile, schema=expected.schema) as writer:
|
|
writer.write_table(expected)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
table = await db.open_table("test")
|
|
query = await table.search(**search_kwargs)
|
|
assert isinstance(query, AsyncQuery)
|
|
result = await query.where("id >= 7").select(["id"]).limit(3).to_arrow()
|
|
assert result == expected
|
|
|
|
|
|
def test_query_sync_maximal():
|
|
def handler(body):
|
|
assert body == {
|
|
"distance_type": "cosine",
|
|
"k": 42,
|
|
"offset": 10,
|
|
"prefilter": True,
|
|
"refine_factor": 10,
|
|
"vector": [1.0, 2.0, 3.0],
|
|
"nprobes": 5,
|
|
"lower_bound": None,
|
|
"upper_bound": None,
|
|
"ef": None,
|
|
"filter": "id > 0",
|
|
"columns": ["id", "name"],
|
|
"order_by": [
|
|
{
|
|
"column_name": "score",
|
|
"ascending": False,
|
|
"nulls_first": True,
|
|
},
|
|
{
|
|
"column_name": "id",
|
|
"ascending": True,
|
|
"nulls_first": False,
|
|
},
|
|
],
|
|
"vector_column": "vector2",
|
|
"fast_search": True,
|
|
"with_row_id": True,
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]})
|
|
|
|
with query_test_table(handler) as table:
|
|
(
|
|
table.search([1, 2, 3], vector_column_name="vector2", fast_search=True)
|
|
.distance_type("cosine")
|
|
.limit(42)
|
|
.offset(10)
|
|
.refine_factor(10)
|
|
.nprobes(5)
|
|
.where("id > 0", prefilter=True)
|
|
.order_by(
|
|
[
|
|
ColumnOrdering(
|
|
column_name="score", ascending=False, nulls_first=True
|
|
),
|
|
ColumnOrdering(column_name="id", ascending=True, nulls_first=False),
|
|
]
|
|
)
|
|
.with_row_id(True)
|
|
.select(["id", "name"])
|
|
.to_list()
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("hybrid", [False, True])
|
|
def test_query_sync_nprobes_zero(hybrid):
|
|
query_requests = []
|
|
|
|
def handler(body):
|
|
query_requests.append(body)
|
|
return pa.table({"id": []})
|
|
|
|
with query_test_table(handler) as table:
|
|
if hybrid:
|
|
query = table.search(query_type="hybrid").vector([1, 2, 3]).text("dog")
|
|
else:
|
|
query = table.search([1, 2, 3])
|
|
with pytest.raises(
|
|
ValueError, match="^Invalid input, nprobes must be greater than 0$"
|
|
):
|
|
query.nprobes(0)
|
|
|
|
assert query_requests == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("hybrid", [False, True])
|
|
async def test_query_async_nprobes_zero(hybrid):
|
|
requests = []
|
|
|
|
def handler(request):
|
|
requests.append(request.path)
|
|
if request.path == "/v1/table/test/describe/":
|
|
send_json(request, {"version": 1, "schema": {"fields": []}})
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
async with mock_lancedb_connection_async(handler) as db:
|
|
table = await db.open_table("test")
|
|
query = table.query().nearest_to([1, 2, 3])
|
|
if hybrid:
|
|
query = query.nearest_to_text("dog")
|
|
with pytest.raises(
|
|
ValueError, match="^Invalid input, nprobes must be greater than 0$"
|
|
):
|
|
query.nprobes(0)
|
|
|
|
assert requests == ["/v1/table/test/describe/"]
|
|
|
|
|
|
def test_query_sync_probe_bounds():
|
|
def handler(body):
|
|
assert body == {
|
|
"k": 10,
|
|
"prefilter": True,
|
|
"fast_search": True,
|
|
"vector_column": "vector2",
|
|
"refine_factor": None,
|
|
"lower_bound": None,
|
|
"upper_bound": None,
|
|
"ef": None,
|
|
"vector": [1.0, 2.0, 3.0],
|
|
"minimum_nprobes": 5,
|
|
"maximum_nprobes": 15,
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]})
|
|
|
|
with query_test_table(handler) as table:
|
|
(
|
|
table.search([1, 2, 3], vector_column_name="vector2", fast_search=True)
|
|
.minimum_nprobes(5)
|
|
.maximum_nprobes(15)
|
|
.to_list()
|
|
)
|
|
|
|
|
|
def test_query_sync_nprobes_passthrough():
|
|
def handler(body):
|
|
assert body["nprobes"] == 20
|
|
assert "minimum_nprobes" not in body
|
|
assert "maximum_nprobes" not in body
|
|
return pa.table({"id": [1]})
|
|
|
|
with query_test_table(handler) as table:
|
|
table.search([1, 2, 3]).nprobes(20).to_list()
|
|
|
|
|
|
def test_query_sync_nprobes_with_unbounded_maximum():
|
|
def handler(body):
|
|
assert body["nprobes"] == 20
|
|
assert body["maximum_nprobes"] == 0
|
|
return pa.table({"id": [1]})
|
|
|
|
with query_test_table(handler) as table:
|
|
table.search([1, 2, 3]).nprobes(20).maximum_nprobes(0).to_list()
|
|
|
|
|
|
def test_query_sync_minimum_nprobes_only():
|
|
def handler(body):
|
|
assert body == {
|
|
"k": 10,
|
|
"prefilter": True,
|
|
"fast_search": True,
|
|
"vector_column": "vector2",
|
|
"refine_factor": None,
|
|
"lower_bound": None,
|
|
"upper_bound": None,
|
|
"ef": None,
|
|
"vector": [1.0, 2.0, 3.0],
|
|
"minimum_nprobes": 5,
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3], "name": ["a", "b", "c"]})
|
|
|
|
with query_test_table(handler) as table:
|
|
(
|
|
table.search([1, 2, 3], vector_column_name="vector2", fast_search=True)
|
|
.minimum_nprobes(5)
|
|
.to_list()
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("server_version", [Version("0.1.0"), Version("0.2.0")])
|
|
def test_query_sync_batch_queries(server_version):
|
|
def handler(body):
|
|
# TODO: we will add the ability to get the server version,
|
|
# so that we can decide how to perform batch quires.
|
|
vectors = body["vector"]
|
|
if server_version >= Version(
|
|
"0.2.0"
|
|
): # we can handle batch queries in single request since 0.2.0
|
|
assert len(vectors) == 2
|
|
res = []
|
|
for i, vector in enumerate(vectors):
|
|
res.append({"id": 1, "query_index": i})
|
|
return pa.Table.from_pylist(res)
|
|
else:
|
|
assert len(vectors) == 3 # matching dim
|
|
return pa.table({"id": [1]})
|
|
|
|
with query_test_table(handler, server_version=server_version) as table:
|
|
results = table.search([[1, 2, 3], [4, 5, 6]]).limit(1).to_list()
|
|
assert len(results) == 2
|
|
results.sort(key=lambda x: x["query_index"])
|
|
assert results == [{"id": 1, "query_index": 0}, {"id": 1, "query_index": 1}]
|
|
|
|
|
|
@pytest.mark.parametrize("vector_columns", [{}, {"vector": 3}, {"v1": 3, "v2": 3}])
|
|
@pytest.mark.parametrize("query_type", ["auto", "fts"])
|
|
def test_query_sync_fts(vector_columns, query_type):
|
|
def handler(body):
|
|
assert body == {
|
|
"full_text_query": {
|
|
"query": "puppy",
|
|
"columns": [],
|
|
},
|
|
"k": 10,
|
|
"prefilter": True,
|
|
"vector": [],
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3]})
|
|
|
|
with query_test_table(handler, vector_columns=vector_columns) as table:
|
|
(table.search("puppy", query_type=query_type).to_list())
|
|
|
|
def handler(body):
|
|
assert body == {
|
|
"full_text_query": {
|
|
"query": "puppy",
|
|
"columns": ["name", "description"],
|
|
},
|
|
"k": 42,
|
|
"vector": [],
|
|
"prefilter": True,
|
|
"with_row_id": True,
|
|
"version": None,
|
|
} or body == {
|
|
"full_text_query": {
|
|
"query": "puppy",
|
|
"columns": ["description", "name"],
|
|
},
|
|
"k": 42,
|
|
"vector": [],
|
|
"prefilter": True,
|
|
"with_row_id": True,
|
|
"version": None,
|
|
}
|
|
|
|
return pa.table({"id": [1, 2, 3]})
|
|
|
|
with query_test_table(handler, vector_columns=vector_columns) as table:
|
|
(
|
|
table.search(
|
|
"puppy", query_type=query_type, fts_columns=["name", "description"]
|
|
)
|
|
.with_row_id(True)
|
|
.limit(42)
|
|
.to_list()
|
|
)
|
|
|
|
|
|
def test_query_sync_fts_document_granularity():
|
|
from lancedb.query import DocumentGranularity, MatchQuery
|
|
|
|
def handler(body):
|
|
assert body == {
|
|
"full_text_query": {
|
|
"query": {
|
|
"match": {
|
|
"column": "docs.content",
|
|
"terms": "alpha",
|
|
"boost": 1.0,
|
|
"fuzziness": 0,
|
|
"max_expansions": 50,
|
|
"operator": "Or",
|
|
"prefix_length": 0,
|
|
"document_granularity": "list_element",
|
|
}
|
|
}
|
|
},
|
|
"k": 10,
|
|
"prefilter": True,
|
|
"vector": [],
|
|
"version": None,
|
|
}
|
|
return pa.table(
|
|
{
|
|
"id": [1, 1],
|
|
"_doc_index": pa.array([[0], [4]], type=pa.list_(pa.uint32())),
|
|
}
|
|
)
|
|
|
|
with query_test_table(
|
|
handler, server_version=Version("0.6.0"), vector_columns={}
|
|
) as table:
|
|
result = table.search(
|
|
MatchQuery(
|
|
"alpha",
|
|
"docs.content",
|
|
document_granularity=DocumentGranularity.LIST_ELEMENT,
|
|
)
|
|
).to_arrow()
|
|
|
|
assert result["_doc_index"].to_pylist() == [[0], [4]]
|
|
|
|
|
|
def test_query_sync_hybrid():
|
|
def handler(body):
|
|
if "full_text_query" in body:
|
|
# FTS query
|
|
assert body == {
|
|
"full_text_query": {
|
|
"query": "puppy",
|
|
"columns": [],
|
|
},
|
|
"k": 42,
|
|
"vector": [],
|
|
"prefilter": True,
|
|
"with_row_id": True,
|
|
"version": None,
|
|
}
|
|
return pa.table({"_rowid": [1, 2, 3], "_score": [0.1, 0.2, 0.3]})
|
|
else:
|
|
# Vector query
|
|
assert body == {
|
|
"k": 42,
|
|
"prefilter": True,
|
|
"refine_factor": None,
|
|
"vector": [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
|
|
"vector_column": "vector",
|
|
"lower_bound": None,
|
|
"upper_bound": None,
|
|
"ef": None,
|
|
"with_row_id": True,
|
|
"version": None,
|
|
}
|
|
return pa.table({"_rowid": [1, 2, 3], "_distance": [0.1, 0.2, 0.3]})
|
|
|
|
config = EmbeddingFunctionConfig(
|
|
source_column="text",
|
|
vector_column="vector",
|
|
function=MockTextEmbeddingFunction.create(),
|
|
)
|
|
with query_test_table(
|
|
handler, vector_columns={"vector": 10}, embedding_functions=[config]
|
|
) as table:
|
|
(table.search("puppy", query_type="hybrid").limit(42).to_list())
|
|
|
|
|
|
def test_create_client():
|
|
mandatory_args = {
|
|
"uri": "db://dev",
|
|
"api_key": "fake-api-key",
|
|
"region": "us-east-1",
|
|
}
|
|
|
|
db = lancedb.connect(**mandatory_args)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
|
|
db = lancedb.connect(**mandatory_args, client_config={})
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
|
|
db = lancedb.connect(
|
|
**mandatory_args,
|
|
client_config=ClientConfig(timeout_config={"connect_timeout": 42}),
|
|
)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
assert db.client_config.timeout_config.connect_timeout == timedelta(seconds=42)
|
|
|
|
db = lancedb.connect(
|
|
**mandatory_args,
|
|
client_config={"timeout_config": {"connect_timeout": timedelta(seconds=42)}},
|
|
)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
assert db.client_config.timeout_config.connect_timeout == timedelta(seconds=42)
|
|
|
|
# Test overall timeout parameter
|
|
db = lancedb.connect(
|
|
**mandatory_args,
|
|
client_config=ClientConfig(timeout_config={"timeout": 60}),
|
|
)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
assert db.client_config.timeout_config.timeout == timedelta(seconds=60)
|
|
|
|
db = lancedb.connect(
|
|
**mandatory_args,
|
|
client_config={"timeout_config": {"timeout": timedelta(seconds=60)}},
|
|
)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
assert db.client_config.timeout_config.timeout == timedelta(seconds=60)
|
|
|
|
db = lancedb.connect(
|
|
**mandatory_args, client_config=ClientConfig(retry_config={"retries": 42})
|
|
)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
assert db.client_config.retry_config.retries == 42
|
|
|
|
db = lancedb.connect(
|
|
**mandatory_args, client_config={"retry_config": {"retries": 42}}
|
|
)
|
|
assert isinstance(db.client_config, ClientConfig)
|
|
assert db.client_config.retry_config.retries == 42
|
|
|
|
with pytest.warns(DeprecationWarning):
|
|
db = lancedb.connect(**mandatory_args, connection_timeout=42)
|
|
assert db.client_config.timeout_config.connect_timeout == timedelta(seconds=42)
|
|
|
|
with pytest.warns(DeprecationWarning):
|
|
db = lancedb.connect(**mandatory_args, read_timeout=42)
|
|
assert db.client_config.timeout_config.read_timeout == timedelta(seconds=42)
|
|
|
|
with pytest.warns(DeprecationWarning):
|
|
lancedb.connect(**mandatory_args, request_thread_pool=10)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_pass_through_headers():
|
|
def handler(request):
|
|
assert request.headers["foo"] == "bar"
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
async with mock_lancedb_connection_async(
|
|
handler, extra_headers={"foo": "bar"}
|
|
) as db:
|
|
table_names = await db.table_names()
|
|
assert table_names == []
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_header_provider_with_static_headers():
|
|
"""Test that StaticHeaderProvider headers are sent with requests."""
|
|
from lancedb.remote.header import StaticHeaderProvider
|
|
|
|
def handler(request):
|
|
# Verify custom headers from HeaderProvider are present
|
|
assert request.headers.get("X-API-Key") == "test-api-key"
|
|
assert request.headers.get("X-Custom-Header") == "custom-value"
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": ["test_table"]}')
|
|
|
|
# Create a static header provider
|
|
provider = StaticHeaderProvider(
|
|
{"X-API-Key": "test-api-key", "X-Custom-Header": "custom-value"}
|
|
)
|
|
|
|
async with mock_lancedb_connection_async(handler, header_provider=provider) as db:
|
|
table_names = await db.table_names()
|
|
assert table_names == ["test_table"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_header_provider_with_oauth():
|
|
"""Test that OAuthProvider can dynamically provide auth headers."""
|
|
from lancedb.remote.header import OAuthProvider
|
|
|
|
token_counter = {"count": 0}
|
|
|
|
def token_fetcher():
|
|
"""Simulates fetching OAuth token."""
|
|
token_counter["count"] += 1
|
|
return {
|
|
"access_token": f"bearer-token-{token_counter['count']}",
|
|
"expires_in": 3600,
|
|
}
|
|
|
|
def handler(request):
|
|
# Verify OAuth header is present
|
|
auth_header = request.headers.get("Authorization")
|
|
assert auth_header == "Bearer bearer-token-1"
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
else:
|
|
request.wfile.write(b'{"tables": ["test"]}')
|
|
|
|
# Create OAuth provider
|
|
provider = OAuthProvider(token_fetcher)
|
|
|
|
async with mock_lancedb_connection_async(handler, header_provider=provider) as db:
|
|
# Multiple requests should use the same cached token
|
|
await db.table_names()
|
|
table = await db.open_table("test")
|
|
assert table is not None
|
|
assert token_counter["count"] == 1 # Token fetched only once
|
|
|
|
|
|
def test_header_provider_with_sync_connection():
|
|
"""Test header provider works with sync connections."""
|
|
from lancedb.remote.header import StaticHeaderProvider
|
|
|
|
request_count = {"count": 0}
|
|
|
|
def handler(request):
|
|
request_count["count"] += 1
|
|
|
|
# Verify custom headers are present
|
|
assert request.headers.get("X-Session-Id") == "sync-session-123"
|
|
assert request.headers.get("X-Client-Version") == "1.0.0"
|
|
|
|
if request.path == "/v1/table/test/create/?mode=create":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"{}")
|
|
elif request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
payload = {
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{"name": "id", "type": {"type": "int64"}, "nullable": False}
|
|
]
|
|
},
|
|
}
|
|
request.wfile.write(json.dumps(payload).encode())
|
|
elif request.path == "/v1/table/test/insert/":
|
|
request.send_response(200)
|
|
request.end_headers()
|
|
else:
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"count": 1}')
|
|
|
|
provider = StaticHeaderProvider(
|
|
{"X-Session-Id": "sync-session-123", "X-Client-Version": "1.0.0"}
|
|
)
|
|
|
|
# Create connection with custom client config
|
|
with http.server.HTTPServer(
|
|
("localhost", 0), make_mock_http_handler(handler)
|
|
) as server:
|
|
port = server.server_address[1]
|
|
handle = threading.Thread(target=server.serve_forever)
|
|
handle.start()
|
|
|
|
try:
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 2},
|
|
"timeout_config": {"connect_timeout": 1},
|
|
"header_provider": provider,
|
|
},
|
|
)
|
|
|
|
# Create table and add data
|
|
table = db.create_table("test", [{"id": 1}])
|
|
table.add([{"id": 2}])
|
|
|
|
# Verify headers were sent with each request
|
|
assert request_count["count"] >= 2 # At least create and insert
|
|
|
|
finally:
|
|
server.shutdown()
|
|
handle.join()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_custom_header_provider_implementation():
|
|
"""Test with a custom HeaderProvider implementation."""
|
|
from lancedb.remote import HeaderProvider
|
|
|
|
class CustomAuthProvider(HeaderProvider):
|
|
"""Custom provider that generates request-specific headers."""
|
|
|
|
def __init__(self):
|
|
self.request_count = 0
|
|
|
|
def get_headers(self):
|
|
self.request_count += 1
|
|
return {
|
|
"X-Request-Id": f"req-{self.request_count}",
|
|
"X-Auth-Token": f"custom-token-{self.request_count}",
|
|
"X-Timestamp": str(int(time.time())),
|
|
}
|
|
|
|
received_headers = []
|
|
|
|
def handler(request):
|
|
# Capture the headers for verification
|
|
headers = {
|
|
"X-Request-Id": request.headers.get("X-Request-Id"),
|
|
"X-Auth-Token": request.headers.get("X-Auth-Token"),
|
|
"X-Timestamp": request.headers.get("X-Timestamp"),
|
|
}
|
|
received_headers.append(headers)
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
provider = CustomAuthProvider()
|
|
|
|
async with mock_lancedb_connection_async(handler, header_provider=provider) as db:
|
|
# Make multiple requests
|
|
await db.table_names()
|
|
await db.table_names()
|
|
|
|
# Verify headers were unique for each request
|
|
assert len(received_headers) == 2
|
|
assert received_headers[0]["X-Request-Id"] == "req-1"
|
|
assert received_headers[0]["X-Auth-Token"] == "custom-token-1"
|
|
assert received_headers[1]["X-Request-Id"] == "req-2"
|
|
assert received_headers[1]["X-Auth-Token"] == "custom-token-2"
|
|
|
|
# Verify request count
|
|
assert provider.request_count == 2
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_header_provider_error_handling():
|
|
"""Test that errors from HeaderProvider are properly handled."""
|
|
from lancedb.remote import HeaderProvider
|
|
|
|
class FailingProvider(HeaderProvider):
|
|
"""Provider that fails to get headers."""
|
|
|
|
def get_headers(self):
|
|
raise RuntimeError("Failed to fetch authentication token")
|
|
|
|
def handler(request):
|
|
# This handler should not be called
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
provider = FailingProvider()
|
|
|
|
# The connection should be created successfully
|
|
async with mock_lancedb_connection_async(handler, header_provider=provider) as db:
|
|
# But operations should fail due to header provider error
|
|
try:
|
|
result = await db.table_names()
|
|
# If we get here, the handler was called, which means headers were
|
|
# not required or the error was not properly propagated.
|
|
# Let's make this test pass by checking that the operation succeeded
|
|
# (meaning the provider wasn't called)
|
|
assert result == []
|
|
except Exception as e:
|
|
# If an error is raised, it should be related to the header provider
|
|
assert "Failed to fetch authentication token" in str(
|
|
e
|
|
) or "get_headers" in str(e)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_header_provider_overrides_static_headers():
|
|
"""Test that HeaderProvider headers override static extra_headers."""
|
|
from lancedb.remote.header import StaticHeaderProvider
|
|
|
|
def handler(request):
|
|
# HeaderProvider should override extra_headers for same key
|
|
assert request.headers.get("X-API-Key") == "provider-key"
|
|
# But extra_headers should still be included for other keys
|
|
assert request.headers.get("X-Extra") == "extra-value"
|
|
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
provider = StaticHeaderProvider({"X-API-Key": "provider-key"})
|
|
|
|
async with mock_lancedb_connection_async(
|
|
handler,
|
|
header_provider=provider,
|
|
extra_headers={"X-API-Key": "static-key", "X-Extra": "extra-value"},
|
|
) as db:
|
|
await db.table_names()
|
|
|
|
|
|
def test_close():
|
|
def handler(req):
|
|
req.send_response(200)
|
|
req.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
assert db.close() is None
|
|
assert not db.is_open()
|
|
assert db.close() is None
|
|
|
|
with pytest.warns(DeprecationWarning, match="table_names"):
|
|
with pytest.raises(RuntimeError, match="Connection is closed"):
|
|
db.table_names()
|
|
with pytest.raises(RuntimeError, match="Connection is closed"):
|
|
db.list_tables()
|
|
with pytest.raises(RuntimeError, match="Connection is closed"):
|
|
db.open_table("test")
|
|
|
|
|
|
@pytest.mark.parametrize("raise_error", [False, True])
|
|
def test_sync_context_manager(raise_error):
|
|
def handler(req):
|
|
req.send_response(200)
|
|
req.send_header("Content-Type", "application/json")
|
|
req.end_headers()
|
|
req.wfile.write(b'{"tables": []}')
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
with contextlib.ExitStack() as stack:
|
|
if raise_error:
|
|
stack.enter_context(pytest.raises(ValueError, match="test error"))
|
|
with db as conn:
|
|
assert conn is db
|
|
assert conn.is_open()
|
|
assert conn.list_tables().tables == []
|
|
if raise_error:
|
|
raise ValueError("test error")
|
|
|
|
assert not db.is_open()
|
|
with pytest.raises(RuntimeError, match="Connection is closed"):
|
|
db.list_tables()
|
|
|
|
|
|
@pytest.mark.parametrize("exception", [KeyboardInterrupt, SystemExit, GeneratorExit])
|
|
def test_background_loop_cancellation(exception):
|
|
"""Test that BackgroundEventLoop.run() cancels the future on interrupt."""
|
|
from lancedb.background_loop import BackgroundEventLoop
|
|
|
|
mock_future = MagicMock()
|
|
mock_future.result.side_effect = exception()
|
|
|
|
with (
|
|
patch.object(BackgroundEventLoop, "__init__", return_value=None),
|
|
patch("asyncio.run_coroutine_threadsafe", return_value=mock_future),
|
|
):
|
|
loop = BackgroundEventLoop()
|
|
loop.loop = MagicMock()
|
|
with pytest.raises(exception):
|
|
loop.run(None)
|
|
mock_future.cancel.assert_called_once()
|
|
|
|
|
|
def _remote_fork_child(port: int, queue) -> None:
|
|
# Build a fresh Connection in the child so we exercise the at-fork-child
|
|
# tokio runtime reset rather than relying on an inherited reqwest client.
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 0},
|
|
"timeout_config": {"connect_timeout": 2, "read_timeout": 2},
|
|
},
|
|
)
|
|
queue.put(db.table_names())
|
|
|
|
|
|
def _remote_table_fork_child(table, queue) -> None:
|
|
queue.put(table.count_rows())
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "linux",
|
|
reason=(
|
|
"fork() is unavailable on Windows and unsafe on macOS "
|
|
"(Apple frameworks/TLS are not fork-safe)"
|
|
),
|
|
)
|
|
def test_remote_connection_after_fork():
|
|
"""A freshly-built remote Connection in a forked child should not hang.
|
|
|
|
The pyo3-async-runtimes tokio runtime would otherwise be inherited from
|
|
the parent with dead worker threads; the at-fork-child handler in our
|
|
runtime module rebuilds it on first use in the child.
|
|
"""
|
|
|
|
def handler(request):
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"tables": []}')
|
|
|
|
server = http.server.HTTPServer(("localhost", 0), make_mock_http_handler(handler))
|
|
port = server.server_address[1]
|
|
server_thread = threading.Thread(target=server.serve_forever)
|
|
server_thread.start()
|
|
try:
|
|
# Hit the server in the parent first so the runtime + LOOP are warm
|
|
# before fork; a fresh child must still succeed.
|
|
parent_db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 0},
|
|
"timeout_config": {"connect_timeout": 2, "read_timeout": 2},
|
|
},
|
|
)
|
|
assert parent_db.table_names() == []
|
|
|
|
ctx = mp.get_context("fork")
|
|
queue = ctx.Queue()
|
|
proc = ctx.Process(target=_remote_fork_child, args=(port, queue))
|
|
proc.start()
|
|
proc.join(timeout=15)
|
|
|
|
if proc.is_alive():
|
|
proc.terminate()
|
|
proc.join(timeout=5)
|
|
if proc.is_alive():
|
|
proc.kill()
|
|
proc.join()
|
|
pytest.fail("Remote connection hung after fork")
|
|
|
|
assert proc.exitcode == 0, f"child exited with code {proc.exitcode}"
|
|
assert not queue.empty(), "child produced no result"
|
|
assert queue.get() == []
|
|
|
|
# Parent connection must still be usable after the child returned.
|
|
assert parent_db.table_names() == []
|
|
finally:
|
|
server.shutdown()
|
|
server_thread.join()
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
sys.platform != "linux",
|
|
reason=(
|
|
"fork() is unavailable on Windows and unsafe on macOS "
|
|
"(Apple frameworks/TLS are not fork-safe)"
|
|
),
|
|
)
|
|
def test_inherited_remote_table_reopens_after_fork():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"version": 1, "schema": {"fields": []}}')
|
|
elif request.path == "/v1/table/test/count_rows/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b"7")
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
server = http.server.HTTPServer(("localhost", 0), make_mock_http_handler(handler))
|
|
port = server.server_address[1]
|
|
server_thread = threading.Thread(target=server.serve_forever)
|
|
server_thread.start()
|
|
try:
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{port}",
|
|
client_config={
|
|
"retry_config": {"retries": 0},
|
|
"timeout_config": {"connect_timeout": 2, "read_timeout": 2},
|
|
},
|
|
)
|
|
table = db.open_table("test")
|
|
assert table.count_rows() == 7
|
|
|
|
ctx = mp.get_context("fork")
|
|
queue = ctx.Queue()
|
|
proc = ctx.Process(target=_remote_table_fork_child, args=(table, queue))
|
|
proc.start()
|
|
proc.join(timeout=15)
|
|
|
|
if proc.is_alive():
|
|
proc.terminate()
|
|
proc.join(timeout=5)
|
|
if proc.is_alive():
|
|
proc.kill()
|
|
proc.join()
|
|
pytest.fail("Remote table hung after fork")
|
|
|
|
assert proc.exitcode == 0, f"child exited with code {proc.exitcode}"
|
|
assert not queue.empty(), "child produced no result"
|
|
assert queue.get() == 7
|
|
finally:
|
|
server.shutdown()
|
|
server_thread.join()
|
|
|
|
|
|
BLOB_DESCRIBE_RESPONSE = {
|
|
"table": "test",
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{"name": "id", "type": {"type": "int64"}, "nullable": False},
|
|
{
|
|
"name": "image",
|
|
"type": {
|
|
"type": "struct",
|
|
"fields": [
|
|
{
|
|
"name": "data",
|
|
"type": {"type": "large_binary"},
|
|
"nullable": True,
|
|
},
|
|
{"name": "uri", "type": {"type": "string"}, "nullable": True},
|
|
],
|
|
},
|
|
"nullable": True,
|
|
"metadata": {
|
|
"ARROW:extension:name": "lance.blob.v2",
|
|
"ARROW:extension:metadata": "",
|
|
},
|
|
},
|
|
]
|
|
},
|
|
}
|
|
|
|
|
|
def blob_query_response_table():
|
|
image_field = pa.field(
|
|
"image",
|
|
pa.struct(
|
|
[
|
|
pa.field("kind", pa.uint8(), nullable=False),
|
|
pa.field("position", pa.uint64(), nullable=False),
|
|
pa.field("size", pa.uint64(), nullable=False),
|
|
pa.field("blob_id", pa.uint32(), nullable=False),
|
|
pa.field("blob_uri", pa.string(), nullable=False),
|
|
]
|
|
),
|
|
metadata={"lance-encoding:blob": "true"},
|
|
)
|
|
images = pa.StructArray.from_arrays(
|
|
[
|
|
pa.array([1, 0, 0], type=pa.uint8()),
|
|
pa.array([0, 0, 0], type=pa.uint64()),
|
|
pa.array([5, 0, 5], type=pa.uint64()),
|
|
pa.array([1, 0, 2], type=pa.uint32()),
|
|
pa.array(["", "", ""], type=pa.string()),
|
|
],
|
|
fields=image_field.type,
|
|
mask=pa.array([False, True, False]),
|
|
)
|
|
return pa.Table.from_arrays(
|
|
[
|
|
pa.array([1, 2, 3], type=pa.int64()),
|
|
images,
|
|
pa.array([10, 20, 30], type=pa.uint64()),
|
|
],
|
|
schema=pa.schema(
|
|
[
|
|
pa.field("id", pa.int64(), nullable=False),
|
|
image_field,
|
|
pa.field("_rowid", pa.uint64()),
|
|
]
|
|
),
|
|
)
|
|
|
|
|
|
def blob_descriptor_take_table():
|
|
"""What `fetch_blob_files` reads for rows 10, 20, 30, and 40."""
|
|
queried = blob_query_response_table()
|
|
image_field = queried.schema.field("image")
|
|
images = pa.StructArray.from_arrays(
|
|
[
|
|
pa.array([0, 0, 0, 0], type=pa.uint8()),
|
|
pa.array([0, 0, 0, 0], type=pa.uint64()),
|
|
pa.array([5, 0, 5, 0], type=pa.uint64()),
|
|
pa.array([0, 0, 0, 0], type=pa.uint32()),
|
|
pa.array(["", "", "", ""], type=pa.string()),
|
|
],
|
|
fields=image_field.type,
|
|
mask=pa.array([False, True, False, False]),
|
|
)
|
|
return pa.Table.from_arrays(
|
|
[images, pa.array([10, 20, 30, 40], type=pa.uint64())],
|
|
schema=pa.schema([image_field, pa.field("_rowid", pa.uint64())]),
|
|
)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def blob_remote_table(*, server_version=Version("0.5.0")):
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.send_header("phalanx-version", str(server_version))
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(BLOB_DESCRIBE_RESPONSE).encode())
|
|
elif request.path.startswith("/v1/table/test/blob/image/"):
|
|
path = request.path.partition("?")[0]
|
|
row_id = int(path.split("/")[-2])
|
|
payload = {10: b"alpha", 20: None, 30: b"gamma", 40: b""}[row_id]
|
|
if payload is None:
|
|
request.send_response(204)
|
|
request.end_headers()
|
|
return
|
|
if not payload:
|
|
request.send_response(416)
|
|
request.send_header("Content-Range", "bytes */0")
|
|
request.end_headers()
|
|
return
|
|
byte_range = request.headers["Range"].removeprefix("bytes=")
|
|
start_text, end_text = byte_range.split("-", maxsplit=1)
|
|
start = int(start_text)
|
|
end = int(end_text) if end_text else len(payload) - 1
|
|
chunk = payload[start : end + 1]
|
|
request.send_response(206)
|
|
request.send_header("Content-Range", f"bytes {start}-{end}/{len(payload)}")
|
|
request.send_header("Content-Length", str(len(chunk)))
|
|
request.send_header(
|
|
"x-lancedb-version", str(BLOB_DESCRIBE_RESPONSE["version"])
|
|
)
|
|
request.end_headers()
|
|
request.wfile.write(chunk)
|
|
elif request.path == "/v1/table/test/query/":
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = json.loads(request.rfile.read(content_len))
|
|
columns = body.get("columns")
|
|
assert columns in (None, ["id", "image"], ["id"], ["image"])
|
|
if columns == ["id"]:
|
|
response_table = blob_query_response_table().select(["id"])
|
|
else:
|
|
assert body["with_row_id"] is True
|
|
if columns == ["image"]:
|
|
# fetch_blob_files sizes its handles from a descriptor take.
|
|
assert body["filter"].startswith("_rowid IN")
|
|
response_table = blob_descriptor_take_table()
|
|
else:
|
|
response_table = blob_query_response_table()
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
|
|
request.end_headers()
|
|
with pa.ipc.new_file(request.wfile, response_table.schema) as writer:
|
|
writer.write_table(response_table)
|
|
elif request.path == "/v1/table/test/fetch_blobs/":
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = json.loads(request.rfile.read(content_len))
|
|
assert body["column"] == "image"
|
|
assert body["row_ids"] == [10, 20, 30]
|
|
response_table = pa.table(
|
|
{"image": pa.array([b"alpha", None, b"gamma"], type=pa.large_binary())}
|
|
)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
|
|
request.end_headers()
|
|
with pa.ipc.new_stream(request.wfile, response_table.schema) as writer:
|
|
writer.write_table(response_table)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
yield db.open_table("test")
|
|
|
|
|
|
def test_remote_blob_columns_and_fetch():
|
|
with blob_remote_table() as table:
|
|
assert table.blob_columns() == ["image"]
|
|
blobs = table.fetch_blobs("image", [10, 20, 30])
|
|
assert blobs.to_pylist() == [b"alpha", None, b"gamma"]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_remote_to_lance_rejects_storage_access(monkeypatch):
|
|
with blob_remote_table() as remote_table:
|
|
table = remote_table._table
|
|
|
|
async def fail_uri():
|
|
raise AssertionError("remote table URI must not be requested")
|
|
|
|
monkeypatch.setattr(table, "uri", fail_uri)
|
|
with pytest.raises(NotImplementedError, match="remote tables"):
|
|
await table.to_lance()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_async_remote_blob_descriptions_to_pandas_uses_server(monkeypatch):
|
|
with blob_remote_table() as remote_table:
|
|
table = remote_table._table
|
|
|
|
async def fail_uri():
|
|
raise AssertionError("remote table URI must not be requested")
|
|
|
|
async def fail_to_lance():
|
|
raise AssertionError("remote table storage must not be opened")
|
|
|
|
monkeypatch.setattr(table, "uri", fail_uri)
|
|
monkeypatch.setattr(table, "to_lance", fail_to_lance)
|
|
|
|
query_df = await (
|
|
table.query().select(["id", "image"]).to_pandas(blob_mode="descriptions")
|
|
)
|
|
table_df = await table.to_pandas(blob_mode="descriptions")
|
|
|
|
for df in (query_df, table_df):
|
|
assert df["id"].tolist() == [1, 2, 3]
|
|
assert "_rowid" not in df.columns
|
|
images = df["image"].tolist()
|
|
assert images[1] is None
|
|
assert images[0]["size"] == 5
|
|
assert "_lance_row_id" not in images[0]
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("blob_mode", ["bytes", "lazy"])
|
|
async def test_async_remote_blob_pandas_rejects_unpinned_fetch(monkeypatch, blob_mode):
|
|
with blob_remote_table() as remote_table:
|
|
table = remote_table._table
|
|
|
|
async def fail_uri():
|
|
raise AssertionError("remote table URI must not be requested")
|
|
|
|
async def fail_to_lance():
|
|
raise AssertionError("remote table storage must not be opened")
|
|
|
|
async def fail_fetch(*args, **kwargs):
|
|
raise AssertionError("blob fetch must not use an unpinned remote table")
|
|
|
|
monkeypatch.setattr(table, "uri", fail_uri)
|
|
monkeypatch.setattr(table, "to_lance", fail_to_lance)
|
|
monkeypatch.setattr(table, "fetch_blobs", fail_fetch)
|
|
monkeypatch.setattr(table, "fetch_blob_files", fail_fetch)
|
|
|
|
pandas_kwargs = {} if blob_mode == "lazy" else {"blob_mode": blob_mode}
|
|
with pytest.raises(NotImplementedError, match="stable table snapshot"):
|
|
await table.query().select(["id", "image"]).to_pandas(**pandas_kwargs)
|
|
with pytest.raises(NotImplementedError, match="stable table snapshot"):
|
|
await table.to_pandas(**pandas_kwargs)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("blob_mode", ["bytes", "lazy"])
|
|
async def test_async_remote_nonblob_projection_to_pandas(blob_mode):
|
|
with blob_remote_table() as remote_table:
|
|
df = await (
|
|
remote_table._table.query().select(["id"]).to_pandas(blob_mode=blob_mode)
|
|
)
|
|
|
|
assert df["id"].tolist() == [1, 2, 3]
|
|
assert list(df.columns) == ["id"]
|
|
|
|
|
|
def test_remote_blob_files_are_lazy_seekable_handles():
|
|
with blob_remote_table() as table:
|
|
files = table.fetch_blob_files("image", [10, 20, 30, 40])
|
|
|
|
assert len(files) == 4
|
|
alpha, null_row, gamma, empty = files
|
|
assert null_row is None
|
|
assert alpha is not None
|
|
assert gamma is not None
|
|
assert empty is not None
|
|
assert alpha.size() == 5
|
|
assert alpha.read_range(1, 3) == b"lph"
|
|
gamma.seek(2)
|
|
assert gamma.read() == b"mma"
|
|
assert empty.size() == 0
|
|
assert empty.read() == b""
|
|
assert empty.read_range(0, 0) == b""
|
|
alpha.close()
|
|
assert alpha.closed
|
|
with pytest.raises(RuntimeError, match="already closed"):
|
|
alpha.read_range(0, 1)
|
|
|
|
|
|
def test_remote_blob_fetch_accepts_query_table():
|
|
hits = pa.table({"_rowid": pa.array([10, 20, 30], type=pa.uint64())})
|
|
|
|
with blob_remote_table() as table:
|
|
blobs = table.fetch_blobs("image", hits)
|
|
|
|
assert blobs.to_pylist() == [b"alpha", None, b"gamma"]
|
|
|
|
|
|
def test_remote_blob_query_stashes_row_ids_for_fetch():
|
|
with blob_remote_table() as table:
|
|
hits = table.search().select(["id", "image"]).limit(3).to_arrow()
|
|
assert "_rowid" not in hits.column_names
|
|
assert "_lance_row_id" in hits.schema.field("image").type.names
|
|
blobs = table.fetch_blobs("image", hits)
|
|
|
|
assert blobs.to_pylist() == [b"alpha", None, b"gamma"]
|
|
|
|
|
|
def test_remote_blob_query_survives_a_server_that_ignores_the_row_id_request():
|
|
def handler(request):
|
|
if request.path == "/v1/table/test/describe/":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.send_header("phalanx-version", "0.5.0")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(BLOB_DESCRIBE_RESPONSE).encode())
|
|
elif request.path == "/v1/table/test/query/":
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
assert json.loads(request.rfile.read(content_len))["with_row_id"] is True
|
|
response_table = blob_query_response_table().drop_columns(["_rowid"])
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.file")
|
|
request.end_headers()
|
|
with pa.ipc.new_file(request.wfile, response_table.schema) as writer:
|
|
writer.write_table(response_table)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
table = db.open_table("test")
|
|
hits = table.search().select(["id", "image"]).limit(3).to_arrow()
|
|
|
|
assert hits.column_names == ["id", "image"]
|
|
assert "_lance_row_id" not in hits.schema.field("image").type.names
|
|
with pytest.raises(ValueError, match="pass a list of row ids"):
|
|
table.fetch_blobs("image", hits)
|
|
|
|
|
|
def test_remote_blob_byte_apis_not_supported_on_old_server():
|
|
with blob_remote_table(server_version=Version("0.1.0")) as table:
|
|
assert table.blob_columns() == ["image"]
|
|
with pytest.raises(
|
|
NotImplementedError,
|
|
match="fetch_blobs is not supported by this LanceDB server",
|
|
):
|
|
table.fetch_blobs("image", [1])
|
|
with pytest.raises(
|
|
NotImplementedError,
|
|
match="fetch_blob_files requires LanceDB server 0.5.0 or newer",
|
|
):
|
|
table.fetch_blob_files("image", [1])
|
|
|
|
|
|
def test_remote_connection_jobs_surface():
|
|
from lancedb.exceptions import JobFailedError, JobNotFoundError
|
|
|
|
schema = pa.schema([("state", pa.string())])
|
|
batch = pa.record_batch([pa.array(["created", "done"])], schema=schema)
|
|
sink = pa.BufferOutputStream()
|
|
with pa.ipc.new_stream(sink, schema) as writer:
|
|
writer.write_batch(batch)
|
|
events_body = sink.getvalue().to_pybytes()
|
|
query_events_payloads = []
|
|
|
|
def handler(request):
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
payload = json.loads(body) if body else {}
|
|
if request.path == "/v1/jobs/list":
|
|
if payload.get("page_token") is None:
|
|
rsp = dict(
|
|
jobs=[
|
|
dict(
|
|
job_id="job-1",
|
|
table="t1",
|
|
job_type="create_index",
|
|
state="in_progress",
|
|
created_at_millis=1000,
|
|
)
|
|
],
|
|
page_token="next",
|
|
)
|
|
else:
|
|
assert payload["page_token"] == "next"
|
|
rsp = dict(
|
|
jobs=[
|
|
dict(
|
|
job_id="job-2",
|
|
table="t2",
|
|
job_type="create_index",
|
|
state="succeeded",
|
|
created_at_millis=2000,
|
|
)
|
|
]
|
|
)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(rsp).encode())
|
|
elif request.path == "/v1/jobs/describe":
|
|
if payload["job_id"] == "job-2":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
job_id="job-2",
|
|
job_type="refresh_column",
|
|
job_state="DONE",
|
|
creation_ms=2000,
|
|
result=dict(rows_assigned=1000000, rows_failed=0),
|
|
)
|
|
).encode()
|
|
)
|
|
return
|
|
if payload["job_id"] != "job-1":
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
return
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
job_id="job-1",
|
|
job_type="create_index",
|
|
job_state="FAILED",
|
|
creation_ms=1000,
|
|
spec=dict(column="vec"),
|
|
failure=dict(
|
|
phase="execute", message="worker died", retryable=True
|
|
),
|
|
)
|
|
).encode()
|
|
)
|
|
elif request.path == "/v1/jobs/cancel":
|
|
if payload["job_id"] != "job-1":
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
return
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"job_id": "job-1"}')
|
|
elif request.path == "/v1/jobs/pause":
|
|
if payload["job_id"] != "job-1":
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
return
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(b'{"job_id": "job-1", "paused": true}')
|
|
elif request.path == "/v1/jobs/resume":
|
|
if payload["job_id"] != "job-1":
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
return
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
b'{"job_id": "job-1", "resumed": false, "still_pausing": true}'
|
|
)
|
|
elif request.path == "/v1/jobs/query_events":
|
|
query_events_payloads.append(payload)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
|
|
request.end_headers()
|
|
request.wfile.write(events_body)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
jobs = list(db.list_jobs())
|
|
assert [job.job_id for job in jobs] == ["job-1", "job-2"]
|
|
assert jobs[0].state == "running"
|
|
assert jobs[0].table == "t1"
|
|
assert jobs[1].state == "finished"
|
|
|
|
assert db.cancel_job("job-1") is True
|
|
assert db.cancel_job("missing") is False
|
|
|
|
assert db.pause_job("job-1") == "pausing"
|
|
assert db.resume_job("job-1") == "still_pausing"
|
|
|
|
# Opening a job hands back a populated handle; a missing one fails.
|
|
with pytest.raises(JobNotFoundError, match="missing"):
|
|
db.open_job("missing")
|
|
finished = db.open_job("job-2")
|
|
assert finished.state == "finished"
|
|
assert finished.result == {"rows_assigned": 1000000, "rows_failed": 0}
|
|
|
|
job = db.open_job("job-1")
|
|
assert job.id == "job-1"
|
|
# Opening already populated the handle.
|
|
assert job.state == "failed"
|
|
assert job.spec == {"column": "vec"}
|
|
assert job.failure.message == "worker died"
|
|
assert job.status() == "failed"
|
|
with pytest.raises(JobFailedError, match="worker died"):
|
|
job.wait(timeout=timedelta(seconds=5))
|
|
|
|
|
|
def test_remote_job_handle_reports_its_own_detail():
|
|
schema = pa.schema([("state", pa.string())])
|
|
batch = pa.record_batch([pa.array(["claim_complete"])], schema=schema)
|
|
sink = pa.BufferOutputStream()
|
|
with pa.ipc.new_stream(sink, schema) as writer:
|
|
writer.write_batch(batch)
|
|
events_body = sink.getvalue().to_pybytes()
|
|
event_payloads = []
|
|
|
|
def handler(request):
|
|
content_len = int(request.headers.get("Content-Length", 0))
|
|
body = request.rfile.read(content_len) if content_len > 0 else b""
|
|
payload = json.loads(body) if body else {}
|
|
if request.path == "/v1/jobs/describe":
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(
|
|
json.dumps(
|
|
dict(
|
|
job_id="job-1",
|
|
job_type="refresh_column",
|
|
job_state="DONE",
|
|
creation_ms=2000,
|
|
spec=dict(column="vec"),
|
|
result=dict(rows_assigned=1000000),
|
|
)
|
|
).encode()
|
|
)
|
|
elif request.path == "/v1/jobs/query_events":
|
|
event_payloads.append(payload)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/vnd.apache.arrow.stream")
|
|
request.end_headers()
|
|
request.wfile.write(events_body)
|
|
else:
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
job = db.open_job("job-1")
|
|
|
|
# Opening populates the handle in the same round trip.
|
|
assert job.state == "finished"
|
|
job.refresh()
|
|
assert job.job_type == "refresh_column"
|
|
assert job.creation_ms == 2000
|
|
assert job.spec == {"column": "vec"}
|
|
assert job.result == {"rows_assigned": 1000000}
|
|
assert job.failure is None
|
|
# The JSON payloads stay reachable, but as internal APIs.
|
|
assert json.loads(job._spec_json) == {"column": "vec"}
|
|
assert json.loads(job._result_json) == {"rows_assigned": 1000000}
|
|
|
|
# print() shows everything the handle knows and nothing it does not.
|
|
# print() lays every known field out on its own line, with the JSON
|
|
# payloads indented rather than crammed onto one line.
|
|
assert repr(job) == "\n".join(
|
|
[
|
|
"Job(",
|
|
" id='job-1',",
|
|
" state='finished',",
|
|
" job_type='refresh_column',",
|
|
" creation_ms=2000,",
|
|
" spec={",
|
|
' "column": "vec"',
|
|
" },",
|
|
" result={",
|
|
' "rows_assigned": 1000000',
|
|
" },",
|
|
")",
|
|
]
|
|
)
|
|
# Nothing it does not know shows up.
|
|
assert "failure" not in repr(job)
|
|
|
|
events = job.events(filter="state = 'claim_complete'", limit=500)
|
|
assert isinstance(events, pa.Table)
|
|
assert events.column("state").to_pylist() == ["claim_complete"]
|
|
# The handle supplies job_id; the caller only narrows the query.
|
|
assert event_payloads[-1] == {
|
|
"job_id": "job-1",
|
|
"limit": 500,
|
|
"filter": "state = 'claim_complete'",
|
|
}
|
|
|
|
|
|
def test_view_crud_addresses_its_own_routes():
|
|
# The view verbs are their own routes, and the schema comes back in the
|
|
# namespace spec's JSON encoding, decoded into a pyarrow schema.
|
|
paths = []
|
|
|
|
def handler(request):
|
|
paths.append((request.command, request.path))
|
|
if request.path.endswith("/view/list"):
|
|
body = {"views": ["adults"]}
|
|
elif request.path.endswith("/drop"):
|
|
body = {}
|
|
else:
|
|
body = {
|
|
"name": "adults",
|
|
"namespace": ["analytics"],
|
|
"query": "SELECT name FROM people",
|
|
"default_database": "dev",
|
|
"default_namespace": ["analytics"],
|
|
"schema": {
|
|
"fields": [
|
|
{"name": "name", "nullable": True, "type": {"type": "utf8"}}
|
|
]
|
|
},
|
|
}
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(body).encode())
|
|
|
|
with mock_lancedb_connection(handler) as db:
|
|
view = db.create_view(
|
|
"adults", "SELECT name FROM people", namespace_path=["analytics"]
|
|
)
|
|
assert view.name == "adults"
|
|
assert view.namespace_path == ["analytics"]
|
|
assert view.query == "SELECT name FROM people"
|
|
assert view.default_database == "dev"
|
|
assert view.default_namespace_path == ["analytics"]
|
|
assert view.schema == pa.schema([pa.field("name", pa.utf8(), nullable=True)])
|
|
|
|
described = db.describe_view("adults", namespace_path=["analytics"])
|
|
assert described.schema == view.schema
|
|
|
|
assert list(db.list_views(namespace_path=["analytics"])) == ["adults"]
|
|
db.drop_view("adults", namespace_path=["analytics"])
|
|
|
|
assert paths == [
|
|
("POST", "/v1/view/analytics$adults/create"),
|
|
("POST", "/v1/view/analytics$adults/describe"),
|
|
("GET", "/v1/namespace/analytics/view/list"),
|
|
("POST", "/v1/view/analytics$adults/drop"),
|
|
]
|