Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions src/arcadedb/_native/schema_objects.py
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,11 @@ def __init__(self, database, type_name, prop, name=None):
def _resolve_lsm_indexes(self):
return [self._shim]

def _check_live(self):
# every search and counter names the index in its statement, so a rebuilt index is found by name on each call:
# there is no cached engine object to go stale (the JPype wrapper re-resolves one here)
return

def _run_search(
self, *, query_vector, k, allowed_rids, approximate, ef_search=None
):
Expand Down Expand Up @@ -283,6 +288,8 @@ def _lookup_query_vector_by_key(self, key):

def get_stats(self):
"""The engine's live counters of the primary bucket index, from its ``schema:index:<name>`` row (the row's own columns left out)."""
# read afresh: REBUILD INDEX builds new bucket indexes, so the name a memoized read holds can be gone
self._shim.__dict__.pop("_engine_info", None)
bucket = self._shim.engine_info()["bucket"]
rows = self._database.query(
"sql",
Expand Down
54 changes: 46 additions & 8 deletions src/arcadedb/vector.py
Original file line number Diff line number Diff line change
Expand Up @@ -286,7 +286,33 @@ def __init__(self, java_index, database):
# Resolved once per wrapper: the underlying LSM indexes and the
# PRODUCT-quantization readiness check are stable for the lifetime of
# this wrapper, but were previously re-derived on every search (several
# JPype crossings per query).
# JPype crossings per query). They are reset when _check_live() swaps in
# a rebuilt Java index.
self._lsm_indexes = None
self._pq_ready = False

def _check_live(self):
"""Re-resolve the Java index by name when SQL ``REBUILD INDEX`` (or a drop) invalidated it.

``REBUILD INDEX`` drops the Java index and builds a new object under the same
name. The old object answers searches and counters empty instead of raising,
so a wrapper held across a rebuild would return nothing without an error.
Costs one JPype crossing, ``isValid()`` on the cached object, per public call;
the lookup by name runs only when the object is no longer valid.

Raises:
ArcadeDBError: the index no longer exists in the schema.
"""
if self._java_index.isValid():
return
name = str(self._java_index.getName())
try:
fresh = self._database._java_db.getSchema().getIndexByName(name)
except Exception as e:
raise ArcadeDBError(
f"Vector index '{name}' no longer exists: it was dropped"
) from e
self._java_index = fresh
self._lsm_indexes = None
self._pq_ready = False

Expand Down Expand Up @@ -614,6 +640,12 @@ def find_nearest(
- record: The matching ArcadeDB record (Vertex, Document, or Edge)
- score: The similarity score/distance
"""
self._check_live()
return self._find_nearest_live(query_vector, k, ef_search, allowed_rids)

def _find_nearest_live(self, query_vector, k, ef_search, allowed_rids):
# find_nearest after _check_live(): find_nearest_by_key checks once and
# comes here, so a call never pays the isValid() crossing twice.
effective_ef_search = self._normalize_ef_search(ef_search)

try:
Expand All @@ -638,6 +670,7 @@ def get_size(self):
Returns:
int: Number of items currently indexed
"""
self._check_live()
try:
# Both TypeIndex and LSMVectorIndex support countEntries()
return self._java_index.countEntries()
Expand All @@ -654,6 +687,7 @@ def get_metadata(self):
``rerank_oversample`` and ``fused_pq`` are present when the engine has
them (ArcadeDB 26.11.1 or later).
"""
self._check_live()
try:
index = self._get_primary_lsm_index()
meta = self._get_primary_metadata()
Expand Down Expand Up @@ -715,6 +749,7 @@ def get_stats(self):
>>> stats["vectorCacheHits"], stats["vectorCacheMisses"]
(10399017, 1840948)
"""
self._check_live()
try:
raw = self._get_primary_lsm_index().getStats()
except Exception as e:
Expand Down Expand Up @@ -760,13 +795,9 @@ def find_nearest_by_key(
Returns:
List of tuples: [(record, score), ...]
"""
self._check_live()
query_vector = self._lookup_query_vector_by_key(key)
return self.find_nearest(
query_vector,
k=k,
ef_search=ef_search,
allowed_rids=allowed_rids,
)
return self._find_nearest_live(query_vector, k, ef_search, allowed_rids)

def find_nearest_approximate(
self,
Expand All @@ -790,8 +821,9 @@ def find_nearest_approximate(
Returns:
List of tuples: [(record, score), ...]
"""
self._check_live()
try:
quantization = self.get_quantization()
quantization = self._get_quantization_live()
if quantization != "PRODUCT":
raise ArcadeDBError(
"Approximate search requires quantization=PRODUCT (PQ)"
Expand All @@ -818,6 +850,10 @@ def get_quantization(self):
Returns:
str: "NONE", "INT8", "BINARY", or "PRODUCT"
"""
self._check_live()
return self._get_quantization_live()

def _get_quantization_live(self):
try:
idx_to_check = self._get_primary_lsm_index()

Expand Down Expand Up @@ -848,6 +884,7 @@ def warm_up(self):
ArcadeDBError: the index cannot be warmed, including on an engine
older than 26.10.1, which has no ``warmUp()``.
"""
self._check_live()
try:
warmed_any = False
for index in self._iter_lsm_indexes():
Expand All @@ -868,6 +905,7 @@ def build_graph_now(self):
Useful after bulk inserts/updates to avoid waiting for a search-triggered lazy
build. For TypeIndex wrappers, rebuilds all underlying LSMVectorIndex instances.
"""
self._check_live()
try:
self._ensure_product_quantization_ready()

Expand Down
54 changes: 54 additions & 0 deletions tests/test_vector_index_introspection.py
Original file line number Diff line number Diff line change
Expand Up @@ -270,3 +270,57 @@ def test_alter_database_sets_the_ratio_the_threshold_reads(self, temp_db):
)

assert index.get_stats()["effectiveMutationsThreshold"] == 500


class TestWrapperAcrossRebuildAndDrop:
"""A ``VectorIndex`` held across SQL ``REBUILD INDEX`` keeps serving; across a drop it raises (#116).

``REBUILD INDEX`` replaces the engine's index object. The JVM wrapper used to keep the old one, which answered
``find_nearest`` with no rows and ``totalVectors`` 0 without an error.
"""

ROWS = [(f"v{i}", [1.0 + i % 7, 1.0 + i // 7, 2.0 + i % 3]) for i in range(40)]

def _held_index(self, db):
_make_type(db)
_insert(db, self.ROWS)
index = db.create_vector_index("Doc", "embedding", dimensions=3)
assert len(index.find_nearest([1.0, 1.0, 2.0], k=5)) == 5
assert index.get_stats()["totalVectors"] == len(self.ROWS)
return index

def test_old_wrapper_serves_after_rebuild_index(self, temp_db):
index = self._held_index(temp_db)
quantization = index.get_quantization() # create_vector_index defaults to INT8

temp_db.command("sql", "REBUILD INDEX `Doc[embedding]`")

assert len(index.find_nearest([1.0, 1.0, 2.0], k=5)) == 5
assert index.get_stats()["totalVectors"] == len(self.ROWS)
assert index.get_size() == len(self.ROWS)
assert index.get_metadata()["index_name"] == "Doc[embedding]"
assert index.get_quantization() == quantization
index.warm_up()

def test_old_wrapper_follows_a_second_rebuild(self, temp_db):
index = self._held_index(temp_db)

for _ in range(2):
temp_db.command("sql", "REBUILD INDEX `Doc[embedding]`")
assert len(index.find_nearest([1.0, 1.0, 2.0], k=5)) == 5
assert index.get_stats()["totalVectors"] == len(self.ROWS)

def test_dropped_index_raises_and_never_answers_empty(self, temp_db):
index = self._held_index(temp_db)

temp_db.command("sql", "DROP INDEX `Doc[embedding]`")

with pytest.raises(arcadedb.ArcadeDBError) as excinfo:
index.find_nearest([1.0, 1.0, 2.0], k=5)
if arcadedb.__backend__ == "jvm":
# the JVM wrapper names the index itself; the native one passes the engine's own error on
assert "Doc[embedding]" in str(excinfo.value)
with pytest.raises(arcadedb.ArcadeDBError, match=r"Doc\[embedding\]"):
index.get_stats()
with pytest.raises(arcadedb.ArcadeDBError, match=r"Doc\[embedding\]"):
index.get_size()
Loading