mirror of
https://github.com/lancedb/lancedb.git
synced 2026-10-19 10:12:28 +00:00
## What Renames the MemWAL surface in two passes, across Rust, Python, TypeScript, and Java, including the wire format. ### 1. MemWAL APIs drop "LSM" The write path these APIs drive is Lance's MemWAL; "LSM" described its shape rather than naming it. Turning it on and off now 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. flush/compact/checkpoint keep a `_memwal` suffix so `compact_memwal` is not confused with `compact_files` / `optimize`, which compact the base table. Internal names (`LsmMode`, `LsmScanner`, the `merge::lsm` / `query::lsm` modules) are unchanged. ### 2. Stats use SSTable and table shard Aligns the stats surface with the MemWAL naming settled upstream in lance-format/lance#7943 and #7957, where the persisted unit became an **SSTable**. **Generation → SSTable.** A `GenerationStats` describes one flushed MemTable, which is an SSTable. The generation *number* is kept — an SSTable is identified by its generation — so only the noun moved. **Bucket → table shard.** Each entry is one MemWAL shard. "Bucket" names only the hash sharding *transform*, so it was already wrong for `identity` and `year` sharding, which produce shards and no buckets at all. | Before | After | |---|---| | `GenerationStats` | `SsTableStats` | | `BucketStats` | `TableShardStats` | | `stats.buckets` | `stats.table_shards` | | `BucketStats.generations` | `TableShardStats.sstables` | | `include_generation_rows` | `include_sstable_rows` | | `newest_generation` | `newest_sstable_generation` | | `outstanding_generations` | `outstanding_sstables` | Docs and comments follow suit: "seal" → "freeze", "L0" / "flushed generations" → SSTables, "bucket" (meaning a shard) → table shard. `MemWalWriteSpec::bucket`, `num_buckets`, and the `"Sealed"` shard status value are **unchanged** — those name the sharding transform or a server value, not the shard. ## Compatibility Breaking, for an experimental API. The wire format changes with the SDKs, so this must roll out together with the matching server change: - Routes: `/set_lsm_write_spec/` → `/enable_memwal/`, `/unset_lsm_write_spec/` → `/disable_memwal/`, `/get_lsm_write_spec/` → `/get_memwal_spec/`, `/flush_lsm/` → `/flush_memwal/`, `/compact_lsm/` → `/compact_memwal/`, `/get_lsm_stats/` → `/get_memwal_stats/` - Response keys: `lsm_write_spec` → `memwal_spec`, `lsm_stats` → `memwal_stats`, `buckets` → `table_shards`, `generations` → `sstables` - Request fields: `include_generation_rows` → `include_sstable_rows`; `use_lsm` → `use_memwal` on query and merge-insert bodies ## Validation - Rust: `cargo test --features remote -p lancedb --lib --tests` — 1467 lib tests and all integration test targets pass; `cargo clippy` clean - Python: rebuilt the extension; `test_remote_db`, `test_memwal_spec`, `test_merge_insert_lsm`, `test_query`, `test_permutation`, `test_table` — 655 passed; `ruff format` / `ruff check` clean - TypeScript: `pnpm build`; `table`, `merge`, `query` tests — 593 passed; `biome check` clean; `docs/src/js` regenerated with `npm run docs` - Java: `mvn test` — 37 passed (Spotless applied with JDK 11; the pinned google-java-format does not run on JDK 21) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
218 lines
7.5 KiB
Python
218 lines
7.5 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# SPDX-FileCopyrightText: Copyright The LanceDB Authors
|
|
import contextlib
|
|
import http.server
|
|
import json
|
|
import re
|
|
import threading
|
|
|
|
import lancedb
|
|
import pyarrow as pa
|
|
import pytest
|
|
|
|
ARROW_FILE_CONTENT_TYPE = "application/vnd.apache.arrow.file"
|
|
|
|
|
|
def exception_output(e_info: pytest.ExceptionInfo):
|
|
import traceback
|
|
|
|
# skip traceback part, since it's not worth checking in tests
|
|
lines = traceback.format_exception_only(e_info.type, e_info.value)
|
|
return "".join(lines).strip()
|
|
|
|
|
|
def parse_in_list(filter_sql: str) -> list[int]:
|
|
"""Pull the integers out of a `<col> IN (a, b, c)` predicate.
|
|
|
|
Scoped to the parenthesised list so a cast in the SQL adds no phantom values.
|
|
"""
|
|
match = re.search(r"\bIN\s*\(([^)]*)\)", filter_sql, re.IGNORECASE)
|
|
assert match is not None, f"expected an IN list, got: {filter_sql}"
|
|
return [int(m) for m in re.findall(r"-?\d+", match.group(1))]
|
|
|
|
|
|
def is_row_id_take(body) -> bool:
|
|
"""True when a query body fetches specific rows by row id."""
|
|
return "_rowid" in (body.get("filter") or "")
|
|
|
|
|
|
def arrow_file_bytes(table: pa.Table) -> bytes:
|
|
"""Serialize to the Arrow IPC *file* framing the /query/ route answers with."""
|
|
sink = pa.BufferOutputStream()
|
|
with pa.ipc.new_file(sink, table.schema) as writer:
|
|
writer.write_table(table)
|
|
return sink.getvalue().to_pybytes()
|
|
|
|
|
|
class MockPermutationServer:
|
|
"""A stand-in LanceDB server hosting one table whose ``id`` equals its ``_rowid``.
|
|
|
|
Records every ``/query/`` body so tests can assert on the request shapes sent to
|
|
the server, which is the part that has to stay compatible.
|
|
"""
|
|
|
|
def __init__(self, name="remote_data", num_rows=8, vary_scan_order=False):
|
|
self.name = name
|
|
self.num_rows = num_rows
|
|
self.query_bodies = []
|
|
# Stand in for a distributed scan that answers in no fixed order.
|
|
self.vary_scan_order = vary_scan_order
|
|
self.scan_calls = 0
|
|
|
|
def __call__(self, request):
|
|
path = request.path
|
|
if path == f"/v1/table/{self.name}/describe/":
|
|
return self._json(
|
|
request,
|
|
{
|
|
"version": 1,
|
|
"schema": {
|
|
"fields": [
|
|
{"name": "id", "type": {"type": "int64"}, "nullable": False}
|
|
]
|
|
},
|
|
},
|
|
)
|
|
if path == f"/v1/table/{self.name}/get_memwal_spec/":
|
|
self._read_body(request)
|
|
# Null spec: this table has no MemWAL write path.
|
|
return self._json(request, {"memwal_spec": None})
|
|
if path == f"/v1/table/{self.name}/count_rows/":
|
|
self._read_body(request)
|
|
return self._json(request, self.num_rows)
|
|
if path == f"/v1/table/{self.name}/query/":
|
|
return self._query(request, self._read_body(request))
|
|
|
|
# Drain first, so an unexpected route cannot desync a keep-alive connection.
|
|
self._read_body(request)
|
|
request.send_response(404)
|
|
request.end_headers()
|
|
|
|
@property
|
|
def scans(self):
|
|
"""Bodies of the permutation build scan: the row id column, nothing else."""
|
|
return [b for b in self.query_bodies if b.get("columns") == ["_rowid"]]
|
|
|
|
@property
|
|
def takes(self):
|
|
"""Bodies of the row-id takes the loader fetches batches with.
|
|
|
|
Keyed on `_rowid`, not "has a filter": the schema probe also has a predicate.
|
|
"""
|
|
return [b for b in self.query_bodies if is_row_id_take(b)]
|
|
|
|
@staticmethod
|
|
def _read_body(request):
|
|
content_len = int(request.headers.get("Content-Length") or 0)
|
|
return json.loads(request.rfile.read(content_len)) if content_len else {}
|
|
|
|
@staticmethod
|
|
def _json(request, payload):
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", "application/json")
|
|
request.end_headers()
|
|
request.wfile.write(json.dumps(payload).encode())
|
|
|
|
@staticmethod
|
|
def _arrow(request, table):
|
|
body = arrow_file_bytes(table)
|
|
request.send_response(200)
|
|
request.send_header("Content-Type", ARROW_FILE_CONTENT_TYPE)
|
|
request.send_header("Content-Length", str(len(body)))
|
|
request.end_headers()
|
|
request.wfile.write(body)
|
|
|
|
def _query(self, request, body):
|
|
self.query_bodies.append(body)
|
|
|
|
if is_row_id_take(body):
|
|
# A row-id take. Answer ascending, so tests prove the client reorders.
|
|
row_ids = sorted(parse_in_list(body["filter"]))
|
|
return self._arrow(
|
|
request,
|
|
pa.table(
|
|
{
|
|
"id": pa.array(row_ids, pa.int64()),
|
|
"_rowid": pa.array(row_ids, pa.uint64()),
|
|
}
|
|
),
|
|
)
|
|
|
|
if body.get("columns") == ["_rowid"]:
|
|
# The permutation build scan: row ids and nothing else.
|
|
row_ids = list(range(self.num_rows))
|
|
if self.vary_scan_order and self.scan_calls % 2:
|
|
row_ids.reverse()
|
|
self.scan_calls += 1
|
|
return self._arrow(
|
|
request,
|
|
pa.table({"_rowid": pa.array(row_ids, pa.uint64())}),
|
|
)
|
|
|
|
# The schema probe: filtered to nothing, so it carries schema and no rows.
|
|
return self._arrow(request, pa.table({"id": pa.array([], pa.int64())}))
|
|
|
|
|
|
def _make_handler(serve):
|
|
class MockLanceDBHandler(http.server.BaseHTTPRequestHandler):
|
|
def do_GET(self):
|
|
serve(self)
|
|
|
|
def do_POST(self):
|
|
serve(self)
|
|
|
|
def log_message(self, *args):
|
|
pass # keep pytest output readable
|
|
|
|
return MockLanceDBHandler
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def mock_remote_table(server):
|
|
"""Run ``server`` on a local port and yield an open remote table against it.
|
|
|
|
Threading: the loader fans out fetch threads a single-threaded server would
|
|
serialize, hiding the prefetch overlap under test.
|
|
"""
|
|
with http.server.ThreadingHTTPServer(
|
|
("localhost", 0), _make_handler(server)
|
|
) as srv:
|
|
thread = threading.Thread(target=srv.serve_forever)
|
|
thread.start()
|
|
try:
|
|
db = lancedb.connect(
|
|
"db://dev",
|
|
api_key="fake",
|
|
host_override=f"http://localhost:{srv.server_address[1]}",
|
|
client_config={"timeout_config": {"connect_timeout": 5}},
|
|
)
|
|
yield db.open_table(server.name)
|
|
finally:
|
|
srv.shutdown()
|
|
thread.join()
|
|
|
|
|
|
def assert_server_safe_row_id_requests(server):
|
|
"""Assert the loader fetched rows by row id and bounded everything else.
|
|
|
|
`.get`, not `[...]`, so a dropped field reads as the assertion, not a KeyError.
|
|
"""
|
|
for body in server.takes:
|
|
# The fetch needs the row id back to restore the requested order.
|
|
assert body.get("with_row_id") is True, body
|
|
assert "_rowid" in body["filter"], body
|
|
|
|
# Only the one-off permutation scan may scan the whole table; the schema probe is
|
|
# built once per split per epoch. `k == 0` counts as unbounded: lance reads a zero
|
|
# limit as "no limit".
|
|
def is_unbounded(body):
|
|
if is_row_id_take(body):
|
|
return False
|
|
k = body.get("k")
|
|
return k is None or k == 0 or k > server.num_rows
|
|
|
|
unbounded = [b for b in server.query_bodies if is_unbounded(b)]
|
|
assert unbounded == server.scans, (
|
|
f"only the permutation scan may be unbounded, got {unbounded}"
|
|
)
|