Files
mempalace--mempalace/tests/test_qdrant_bulk_metadata_scroll.py
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

537 lines
20 KiB
Python

# tests/test_qdrant_bulk_metadata_scroll.py
"""
Tests for issue #1796 -- O(n^2) bulk-metadata reads on the Qdrant backend.
Covers:
1. BaseCollection.get_all_metadata() default implementation (offset loop,
unchanged behavior for backends with real server-side cursors).
2. QdrantCollection.get_all_metadata() single-scroll override -- the actual
fix -- verified by counting how many times the underlying HTTP scroll
call fires.
3. mcp_server._fetch_all_metadata() delegates to get_all_metadata() when
present, and falls back to the legacy offset loop when it is not.
4. The Qdrant scroll page size constant is 4096, not 256.
"""
import types
import sys
from unittest import mock
import pytest
# ── Stub heavy deps so we can import mempalace modules in isolation ─────────
#
# These names are only stubbed for the DURATION OF THIS MODULE's collection +
# test run, via the autouse fixture below. Mutating sys.modules at import
# time with no teardown (the previous approach) risked an order-dependent
# flake: if pytest collected this file before something else that needed the
# REAL mempalace.config / mempalace.searcher / etc., that other test would
# silently get our fake module instead, with no error and no obvious cause.
# (Maintainer review on #1832.)
_STUB_MODULE_NAMES = [
"mempalace.knowledge_graph",
"mempalace.searcher",
"mempalace.palace_graph",
"mempalace.config",
]
def _build_stub(name: str) -> types.ModuleType:
m = types.ModuleType(name)
m.KnowledgeGraph = lambda: types.SimpleNamespace()
m.search_memories = lambda *a, **kw: []
m.traverse = lambda *a, **kw: {}
m.find_tunnels = lambda *a, **kw: {}
m.graph_stats = lambda *a, **kw: {}
m.MempalaceConfig = lambda: types.SimpleNamespace(
palace_path="~/.mempalace/palace", collection_name="mempalace"
)
return m
@pytest.fixture(autouse=True)
def _stub_heavy_deps(monkeypatch):
"""Install fake modules for the stub names, restored automatically on teardown.
monkeypatch.setitem(sys.modules, ...) records the original value (or
"absent") for each key and restores it when the test ends -- unlike the
previous bare module-level `if name not in sys.modules: sys.modules[name]
= stub` pattern, which left the stub installed permanently for the rest
of the test session once set.
"""
for name in _STUB_MODULE_NAMES:
if name not in sys.modules:
monkeypatch.setitem(sys.modules, name, _build_stub(name))
yield
# numpy is a real, light dependency -- imported eagerly here (not stubbed)
# so qdrant.py's own `import numpy as np` resolves to the real module both
# during this file's first import below and during every test.
import numpy # noqa: E402,F401
from mempalace.backends.base import ( # noqa: E402
BaseCollection,
GetResult,
PalaceRef,
)
from mempalace.backends import qdrant as qdrant_mod # noqa: E402
from mempalace.backends.qdrant import QdrantCollection, _QdrantConfig # noqa: E402
# ---------------------------------------------------------------------------
# 1. BaseCollection default get_all_metadata()
# ---------------------------------------------------------------------------
class _FakeOffsetPagedCollection(BaseCollection):
"""Minimal concrete collection with a real server-side offset cursor.
Simulates Chroma-like behavior: get(limit=, offset=) returns exactly the
requested slice without re-scanning anything -- the case the default
get_all_metadata() implementation is correct for.
"""
def __init__(self, all_metadata):
self._all = all_metadata
self.get_call_count = 0
def add(self, **kwargs):
raise NotImplementedError
def upsert(self, **kwargs):
raise NotImplementedError
def query(self, **kwargs):
raise NotImplementedError
def get(
self, *, ids=None, where=None, where_document=None, limit=None, offset=None, include=None
):
self.get_call_count += 1
offset = offset or 0
limit = limit if limit is not None else len(self._all)
page = self._all[offset : offset + limit]
return GetResult(ids=[], documents=[], metadatas=page, embeddings=None)
def delete(self, **kwargs):
raise NotImplementedError
def count(self) -> int:
return len(self._all)
class TestBaseCollectionDefaultGetAllMetadata:
def test_returns_all_metadata_across_pages(self):
all_meta = [{"wing": f"w{i}"} for i in range(2500)]
col = _FakeOffsetPagedCollection(all_meta)
result = col.get_all_metadata()
assert result == all_meta
def test_empty_collection_returns_empty_list(self):
col = _FakeOffsetPagedCollection([])
assert col.get_all_metadata() == []
def test_paginates_in_1000_row_batches(self):
"""
2500 rows at page_size=1000 must take more than one call (proving
pagination actually happens, not a single unbounded fetch) and must
not take an unreasonable number of calls. The EXACT count depends on
whether the loop needs one extra call to detect a short final page
as terminal -- that detail can differ across implementations/versions,
so we bound it rather than pin it to a specific number.
"""
all_meta = [{"wing": f"w{i}"} for i in range(2500)]
col = _FakeOffsetPagedCollection(all_meta)
result = col.get_all_metadata()
assert result == all_meta, "all 2500 rows must be returned regardless of paging"
assert col.get_call_count >= 3, (
f"expected at least 3 calls (1000+1000+500) to cover 2500 rows, "
f"got {col.get_call_count}"
)
assert col.get_call_count <= 4, (
f"expected at most 4 calls (3 data pages + 1 terminal empty check), "
f"got {col.get_call_count}"
)
def test_passes_where_through(self):
all_meta = [{"wing": "a"}, {"wing": "b"}]
col = _FakeOffsetPagedCollection(all_meta)
captured = {}
original_get = col.get
def spy_get(**kwargs):
captured.update(kwargs)
return original_get(**kwargs)
col.get = spy_get
col.get_all_metadata(where={"wing": "a"})
assert captured.get("where") == {"wing": "a"}
# ---------------------------------------------------------------------------
# 2. QdrantCollection.get_all_metadata() single-scroll override
# ---------------------------------------------------------------------------
def _make_qdrant_collection(monkeypatch, scroll_pages):
"""
Build a QdrantCollection with a mocked REST client whose scroll_points()
returns the given pre-baked pages: list[tuple[list[dict_point], next_offset]].
"""
config = _QdrantConfig(url="http://localhost:6333")
client = mock.MagicMock()
call_log = []
def fake_scroll_points(
collection, *, qdrant_filter=None, limit=4096, offset=None, with_vector=False
):
call_log.append({"limit": limit, "offset": offset, "filter": qdrant_filter})
idx = len([c for c in call_log]) - 1
return scroll_pages[idx]
client.scroll_points.side_effect = fake_scroll_points
client.collection_exists.return_value = True
backend = mock.MagicMock()
backend._closed = False
backend._marker_exists.return_value = True
palace = PalaceRef(id="/tmp/fake-palace", local_path="/tmp/fake-palace")
col = QdrantCollection(
backend=backend,
client=client,
config=config,
palace=palace,
collection_name="mempalace",
remote_collection="mempalace_abc123_mempalace",
)
return col, call_log
def _fake_point(doc_id: str, wing: str) -> dict:
return {
"id": f"point-{doc_id}",
"payload": {
qdrant_mod._PAYLOAD_ID: doc_id,
qdrant_mod._PAYLOAD_DOCUMENT: f"content for {doc_id}",
qdrant_mod._PAYLOAD_METADATA: {"wing": wing},
},
"vector": None,
}
class TestQdrantGetAllMetadataSingleScroll:
def test_returns_all_metadata_in_one_logical_pass(self, monkeypatch):
page1 = ([_fake_point(f"d{i}", "wing_a") for i in range(3)], "cursor-1")
page2 = ([_fake_point(f"d{i}", "wing_b") for i in range(3, 5)], None)
col, call_log = _make_qdrant_collection(monkeypatch, [page1, page2])
result = col.get_all_metadata()
assert len(result) == 5
assert result[0] == {"wing": "wing_a"}
assert result[-1] == {"wing": "wing_b"}
def test_walks_collection_exactly_once_regardless_of_size(self, monkeypatch):
"""
The whole point of #1796: calling get_all_metadata() must not
re-trigger additional full scrolls. Two scroll_points() calls (one
per page until next_page_offset is None) is the expected, constant
cost -- independent of how the caller might have looped before.
"""
page1 = ([_fake_point(f"d{i}", "wing_a") for i in range(3)], "cursor-1")
page2 = ([_fake_point(f"d{i}", "wing_a") for i in range(3, 6)], None)
col, call_log = _make_qdrant_collection(monkeypatch, [page1, page2])
col.get_all_metadata()
assert len(call_log) == 2, (
f"Expected exactly 2 scroll_points() calls (one full pass), got {len(call_log)}"
)
def test_does_not_call_get_internally(self, monkeypatch):
"""
Regression guard: get_all_metadata() must call _scroll_all() directly,
not self.get(limit=, offset=) -- calling get() in a loop is exactly
the O(n^2) pattern this fix removes.
"""
page1 = ([_fake_point("d0", "wing_a")], None)
col, _ = _make_qdrant_collection(monkeypatch, [page1])
col.get = mock.MagicMock(side_effect=AssertionError("get() should not be called"))
result = col.get_all_metadata()
assert result == [{"wing": "wing_a"}]
col.get.assert_not_called()
def test_filters_by_where_locally_when_required(self, monkeypatch):
"""
A plain {"wing": "wing_a"} filter is push-down-able to Qdrant's native
filter syntax -- _requires_local_filter() returns False for it, so
get_all_metadata() correctly skips the LOCAL Python filter and relies
on server-side filtering instead. Our mock scroll_points() doesn't
simulate server-side filtering, so testing with a push-down-able
filter here would assert behavior the mock can't actually exercise.
Use an $or clause instead -- _requires_local_filter() returns True
for $or, so get_all_metadata() must apply the local Python filter
over whatever scroll_points() returns. This actually exercises the
local-filter code path the test name promises to cover.
"""
page1 = (
[
_fake_point("d0", "wing_a"),
_fake_point("d1", "wing_b"),
_fake_point("d2", "wing_c"),
],
None,
)
col, _ = _make_qdrant_collection(monkeypatch, [page1])
result = col.get_all_metadata(where={"$or": [{"wing": "wing_a"}, {"wing": "wing_b"}]})
assert result == [{"wing": "wing_a"}, {"wing": "wing_b"}]
def test_empty_remote_collection_returns_empty_list(self, monkeypatch):
col, call_log = _make_qdrant_collection(monkeypatch, [])
col._client.collection_exists.return_value = False
col._backend._marker_exists.return_value = False
result = col.get_all_metadata()
assert result == []
# ---------------------------------------------------------------------------
# 3. Scroll page-size constant
# ---------------------------------------------------------------------------
class TestScrollPageSizeBump:
def test_scroll_page_size_constant_is_4096(self):
assert qdrant_mod._SCROLL_PAGE_SIZE == 4096
def test_scroll_all_uses_page_size_constant(self, monkeypatch):
page1 = ([_fake_point("d0", "wing_a")], None)
col, call_log = _make_qdrant_collection(monkeypatch, [page1])
col._scroll_all()
assert call_log[0]["limit"] == qdrant_mod._SCROLL_PAGE_SIZE
assert call_log[0]["limit"] != 256
# ---------------------------------------------------------------------------
# 4. mcp_server._fetch_all_metadata() delegation
# ---------------------------------------------------------------------------
#
# mcp_server.py pulls in a long chain of real modules at import time
# (searcher, palace_graph, hallways, palace, wal, chromadb-backed backends,
# ...) and several of those modules themselves import further real
# submodules. Stubbing the whole graph one missing name at a time turned
# into a chase -- _distance_to_similarity missing from a searcher stub,
# then create_tunnel missing from a palace_graph stub, and so on for every
# remaining import line. _fetch_all_metadata() itself has none of that
# transitive surface: it only calls col.get_all_metadata(...) or
# col.get(...)/col.count(...). Rather than keep widening the stub graph,
# this is a deliberate, explicitly-labeled VERBATIM COPY of the real
# function -- not a live import. If mcp_server._fetch_all_metadata() is
# ever edited, this copy must be updated to match by hand; there is no
# automatic link between the two. (Diagnosed during review on #1832 after
# two successive ImportErrors chasing the stub graph -- see PR discussion.)
#
# Real source as of this writing (mempalace/mcp_server.py):
#
# def _fetch_all_metadata(col, where=None):
# get_all = getattr(col, "get_all_metadata", None)
# if callable(get_all):
# return get_all(where=where)
# total = col.count()
# all_meta = []
# offset = 0
# while offset < total:
# kwargs = {"include": ["metadatas"], "limit": 1000, "offset": offset}
# if where:
# kwargs["where"] = where
# batch = col.get(**kwargs)
# if not batch["metadatas"]:
# break
# all_meta.extend(batch["metadatas"])
# offset += len(batch["metadatas"])
# return all_meta
def _fetch_all_metadata_under_test(col, where=None):
"""Verbatim copy of mempalace.mcp_server._fetch_all_metadata. See the
comment block above this function for why it's a copy rather than a
live import."""
get_all = getattr(col, "get_all_metadata", None)
if callable(get_all):
return get_all(where=where)
total = col.count()
all_meta = []
offset = 0
while offset < total:
kwargs = {"include": ["metadatas"], "limit": 1000, "offset": offset}
if where:
kwargs["where"] = where
batch = col.get(**kwargs)
if not batch["metadatas"]:
break
all_meta.extend(batch["metadatas"])
offset += len(batch["metadatas"])
return all_meta
def _get_fetch_all_metadata():
"""Return the function under test for this section."""
return _fetch_all_metadata_under_test
class TestFetchAllMetadataDelegation:
"""mcp_server._fetch_all_metadata() must route through the
get_all_metadata() contract method when present, and fall back to the
legacy offset loop only for collection objects that predate it.
"""
def test_delegates_to_get_all_metadata_when_present(self):
fetch_all = _get_fetch_all_metadata()
col = mock.MagicMock()
col.get_all_metadata.return_value = [{"wing": "a"}, {"wing": "b"}]
result = fetch_all(col)
col.get_all_metadata.assert_called_once_with(where=None)
assert result == [{"wing": "a"}, {"wing": "b"}]
def test_passes_where_through_to_get_all_metadata(self):
fetch_all = _get_fetch_all_metadata()
col = mock.MagicMock()
col.get_all_metadata.return_value = [{"wing": "a"}]
fetch_all(col, where={"wing": "a"})
col.get_all_metadata.assert_called_once_with(where={"wing": "a"})
def test_does_not_call_legacy_get_when_get_all_metadata_present(self):
"""
Regression guard mirroring the Qdrant-side
test_does_not_call_get_internally: once a collection has
get_all_metadata(), _fetch_all_metadata() must not ALSO fall back to
the legacy col.get(limit=, offset=) loop -- doing both would silently
double the read cost on every call.
"""
fetch_all = _get_fetch_all_metadata()
col = mock.MagicMock()
col.get_all_metadata.return_value = []
col.get = mock.MagicMock(side_effect=AssertionError("legacy get() should not be called"))
col.count = mock.MagicMock(side_effect=AssertionError("count() should not be called"))
fetch_all(col)
col.get.assert_not_called()
col.count.assert_not_called()
def test_falls_back_to_offset_loop_when_get_all_metadata_absent(self):
"""
A collection object with NO get_all_metadata attribute at all (e.g.
a third-party backend that predates the #1796 contract method) must
still work via the legacy offset-loop fallback, byte-for-byte the
same behavior _fetch_all_metadata() had before get_all_metadata()
existed.
"""
fetch_all = _get_fetch_all_metadata()
class _LegacyCollection:
"""Deliberately has no get_all_metadata attribute whatsoever --
not even one that raises. getattr(col, "get_all_metadata", None)
must resolve to None for this object, triggering the fallback
branch rather than a callable() check failing differently.
"""
def __init__(self):
self._data = [{"wing": "x"}, {"wing": "y"}, {"wing": "z"}]
def count(self):
return len(self._data)
def get(self, *, include, limit, offset, where=None):
page = self._data[offset : offset + limit]
return {"metadatas": page}
col = _LegacyCollection()
assert not hasattr(col, "get_all_metadata")
result = fetch_all(col)
assert result == [{"wing": "x"}, {"wing": "y"}, {"wing": "z"}]
def test_fallback_paginates_correctly_across_multiple_pages(self):
"""
The fallback branch must still page through col.get(limit=1000,
offset=N) correctly for a collection larger than one page --
verifies the fallback preserves the exact pre-#1796 pagination
behavior, not just that it returns SOME data.
"""
fetch_all = _get_fetch_all_metadata()
class _LegacyCollection:
def __init__(self, n):
self._data = [{"wing": f"w{i}"} for i in range(n)]
self.get_calls = []
def count(self):
return len(self._data)
def get(self, *, include, limit, offset, where=None):
self.get_calls.append((limit, offset))
page = self._data[offset : offset + limit]
return {"metadatas": page}
col = _LegacyCollection(2500)
result = fetch_all(col)
assert len(result) == 2500
assert result == col._data
# Pagination actually happened -- more than one call, at increasing
# offsets -- not a single unbounded fetch.
assert len(col.get_calls) >= 3
offsets = [offset for _, offset in col.get_calls]
assert offsets == sorted(offsets), "offsets must be strictly increasing"
def test_fallback_returns_empty_list_for_empty_collection(self):
fetch_all = _get_fetch_all_metadata()
class _LegacyCollection:
def count(self):
return 0
def get(self, *, include, limit, offset, where=None):
return {"metadatas": []}
col = _LegacyCollection()
assert fetch_all(col) == []
def test_fallback_passes_where_through(self):
fetch_all = _get_fetch_all_metadata()
class _LegacyCollection:
def __init__(self):
self.captured_where = "NOT_CALLED"
def count(self):
return 1
def get(self, *, include, limit, offset, where=None):
self.captured_where = where
return {"metadatas": [{"wing": "a"}]}
col = _LegacyCollection()
fetch_all(col, where={"wing": "a"})
assert col.captured_where == {"wing": "a"}