Skip to content

Commit d87d25b

Browse files
authored
Fix embeddings attach (#1804)
1 parent c768bca commit d87d25b

2 files changed

Lines changed: 60 additions & 36 deletions

File tree

lumen/ai/vector_store.py

Lines changed: 28 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import param
2020
import semchunk
2121

22-
from panel import cache as pn_cache
22+
from panel import cache as pn_cache, state as pn_state
2323
from tqdm.auto import tqdm
2424

2525
from .actor import PROMPTS_DIR, LLMUser
@@ -1116,10 +1116,22 @@ class DuckDBVectorStore(VectorStore):
11161116
doc="Embeddings object for text processing. If None and a URI is provided, loads from the database; else NumpyEmbeddings.",
11171117
)
11181118

1119+
# Class-level registry of connections by URI. Used to close stale
1120+
# connections on reload (e.g. Panel hot-reload) so the file handle is
1121+
# released and a fresh ATTACH can succeed.
1122+
_uri_connections: t.ClassVar[dict[str, duckdb.DuckDBPyConnection]] = {}
1123+
11191124
def __init__(self, **params):
11201125
super().__init__(**params)
11211126
self._add_items_lock = asyncio.Lock()
11221127

1128+
# Reuse existing connection for this URI if still open
1129+
# (e.g. on Panel hot-reload within the same process).
1130+
if self.uri != ":memory:" and self.uri in DuckDBVectorStore._uri_connections:
1131+
self.connection = DuckDBVectorStore._uri_connections[self.uri]
1132+
self._resolve_state()
1133+
return
1134+
11231135
connection = duckdb.connect(":memory:")
11241136
# following the instructions from
11251137
# https://duckdb.org/docs/stable/extensions/vss.html#persistence
@@ -1133,43 +1145,23 @@ def __init__(self, **params):
11331145
if self.embeddings is None:
11341146
self.embeddings = NumpyEmbeddings()
11351147
return
1136-
uri_exists = Path(self.uri).exists()
1137-
direct_connection = False
1138-
try:
1139-
attach_mode = "READ_ONLY" if self.read_only else "READ_WRITE"
1140-
connection.execute(f"ATTACH DATABASE '{self.uri}' AS embedded ({attach_mode});")
1141-
except (duckdb.BinderException, duckdb.IOException) as e:
1142-
err_msg = str(e).lower()
1143-
if "already attached" in err_msg or "unique file handle" in err_msg:
1144-
# File already held by another DuckDB connection in this process
1145-
# (e.g., during Panel hot-reload). Connect directly to the file instead.
1146-
connection.close()
1147-
connection = duckdb.connect(self.uri, read_only=self.read_only)
1148-
connection.execute("LOAD 'vss';")
1149-
connection.execute("SET hnsw_enable_experimental_persistence = true;")
1150-
direct_connection = True
1151-
else:
1152-
raise
1153-
except duckdb.CatalogException:
1154-
# handle "Failure while replaying WAL file"
1155-
# remove .wal uri on corruption
1156-
wal_path = Path(str(self.uri) + ".wal")
1157-
if wal_path.exists():
1158-
wal_path.unlink()
1159-
attach_mode = "READ_ONLY" if self.read_only else "READ_WRITE"
1160-
connection.execute(f"ATTACH DATABASE '{self.uri}' AS embedded ({attach_mode});")
1161-
if not direct_connection:
1162-
connection.execute("USE embedded;")
1163-
self.connection = connection
1164-
has_documents = (
1165-
connection.execute(
1148+
1149+
attach_mode = "READ_ONLY" if self.read_only else "READ_WRITE"
1150+
connection.execute(f"ATTACH DATABASE '{self.uri}' AS embedded ({attach_mode});")
1151+
connection.execute("USE embedded;")
1152+
DuckDBVectorStore._uri_connections[self.uri] = self.connection = connection
1153+
pn_state.on_session_destroyed(lambda session_context: self.close())
1154+
self._resolve_state()
1155+
1156+
def _resolve_state(self):
1157+
"""Set _initialized and resolve embeddings from the active connection."""
1158+
self._initialized = (
1159+
self.connection.execute(
11661160
"SELECT COUNT(*) FROM information_schema.tables WHERE table_name = 'documents';"
11671161
).fetchone()[0]
11681162
> 0
1169-
)
1170-
self._initialized = uri_exists and has_documents
1171-
1172-
if self.uri != ":memory:" and self._initialized:
1163+
) and Path(self.uri).exists()
1164+
if self._initialized:
11731165
config = self._get_embeddings_config()
11741166
if config and self.embeddings is None:
11751167
module_name, class_name = config["class"].rsplit(".", 1)
@@ -1178,7 +1170,6 @@ def __init__(self, **params):
11781170
self.embeddings = embedding_class(**config["params"])
11791171
log_debug(f"Loaded embeddings {class_name} from database.")
11801172
self._check_embeddings_consistency()
1181-
11821173
if self.embeddings is None:
11831174
self.embeddings = NumpyEmbeddings()
11841175

@@ -1674,6 +1665,7 @@ def close(self) -> None:
16741665
if self.connection:
16751666
self.connection.close()
16761667
self.connection = None
1668+
DuckDBVectorStore._uri_connections.pop(self.uri, None)
16771669

16781670

16791671
class ChromaDBVectorStore(VectorStore):

lumen/tests/ai/test_vector_store.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -644,6 +644,38 @@ async def test_api_key_not_stored_in_metadata(self, tmp_path):
644644
assert "api_key" not in params, "api_key should not be stored in metadata"
645645
store.close()
646646

647+
@pytest.mark.asyncio
648+
async def test_reuse_connection_on_same_uri(self, tmp_path):
649+
"""Verifies that creating a second DuckDBVectorStore with the same URI
650+
reuses the existing connection instead of raising an ATTACH error."""
651+
db_path = str(tmp_path / "test_reuse.db")
652+
store1 = DuckDBVectorStore(uri=db_path, embeddings=NumpyEmbeddings())
653+
await store1.add([{"text": "Reuse test doc", "metadata": {"n": 1}}])
654+
655+
# Second store with the same URI — should reuse, not crash.
656+
store2 = DuckDBVectorStore(uri=db_path, embeddings=NumpyEmbeddings())
657+
assert store2.connection is store1.connection
658+
659+
results = await store2.query("Reuse test doc")
660+
assert len(results) == 1
661+
assert results[0]["text"] == "Reuse test doc"
662+
store2.close()
663+
664+
@pytest.mark.asyncio
665+
async def test_fresh_connection_after_close(self, tmp_path):
666+
"""Verifies that after close(), a new store gets a fresh connection
667+
and can still read persisted data."""
668+
db_path = str(tmp_path / "test_fresh.db")
669+
store1 = DuckDBVectorStore(uri=db_path, embeddings=NumpyEmbeddings())
670+
await store1.add([{"text": "Persist me"}])
671+
store1.close()
672+
673+
store2 = DuckDBVectorStore(uri=db_path, embeddings=NumpyEmbeddings())
674+
assert store2.connection is not None
675+
results = await store2.query("Persist me")
676+
assert len(results) == 1
677+
store2.close()
678+
647679

648680
# Sample readme content for testing (mimics real-world metadata files)
649681
SAMPLE_README = """# Population - Data package

0 commit comments

Comments
 (0)