Skip to content

Commit 99709a9

Browse files
authored
feat(cli): Implement filelock for db_path when using the bundled chroma server (#217)
* refactor(cli): implement filelock to protect `db_path`. * fix(cli): fixed singleton implementation. * feat(cli): Remove client cache and fix termination issues * refactor(cli): Refactor client termination to ClientManager * refactor(cli): Use a context manager for client with filelock when necessary * tests(cli): fix failed tests due to ClientManager refactor. * fix(cli): Fix termination and add test case for `kill_servers` * test(cli): fix mocking in mcp tests * tests: Move client manager tests to test_client_manager * tests(cli): fix broken tests * cov * feat: Fix singleton implementation for LockManager and ClientManager * tests(cli): fixed some test warnings * cov * fix(cli): only create lock file when doesn't exist * docs(cli, nvim): remove standalone server requirement for LSP and MCP * Auto generate docs --------- Co-authored-by: Davidyz <[email protected]>
1 parent 1d71b93 commit 99709a9

27 files changed

+889
-810
lines changed

doc/VectorCode-cli.txt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -771,7 +771,8 @@ will use that as the default project root for this process;
771771
Note that:
772772

773773
1. For easier parsing, `--pipe` is assumed to be enabled in LSP mode;
774-
2. At the time this only work with vectorcode setup that uses a **standalone ChromaDB server**, which is not difficult to setup using docker;
774+
2. A `vectorcode.lock` file will be created in your `db_path` directory **if you’re using the bundled chromadb server**. Please do not delete it while a
775+
vectorcode process is running;
775776
3. The LSP server supports `vectorise`, `query` and `ls` subcommands. The other
776777
subcommands may be added in the future.
777778

@@ -789,9 +790,7 @@ features:
789790
- `vectorise`vectorise files into a given project.
790791

791792
To try it out, install the `vectorcode[mcp]` dependency group and the MCP
792-
server is available in the shell as `vectorcode-mcp-server`, and make sure
793-
you’re using a |VectorCode-cli-standalone-chromadb-server| configured in the
794-
|VectorCode-cli-json| via the `host` and `port` options.
793+
server is available in the shell as `vectorcode-mcp-server`.
795794

796795
The MCP server entry point (`vectorcode-mcp-server`) provides some CLI options
797796
that you can use to customise the default behaviour of the server. To view the

doc/VectorCode.txt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -372,9 +372,9 @@ path to the executable) by calling `vim.lsp.config('vectorcode_server', opts)`.
372372
minimal extra config required loading/unloading embedding models;
373373
Progress reports.
374374

375-
Cons Heavy IO overhead because the Requires vectorcode-server; Only
376-
embedding model and database works if you’re using a standalone
377-
client need to be initialised ChromaDB server.
375+
Cons Heavy IO overhead because the Requires vectorcode-server
376+
embedding model and database
377+
client need to be initialised
378378
for every query.
379379
-------------------------------------------------------------------------------
380380
You may choose which backend to use by setting the |VectorCode-`setup`| option

docs/cli.md

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -696,8 +696,9 @@ will:
696696
Note that:
697697
698698
1. For easier parsing, `--pipe` is assumed to be enabled in LSP mode;
699-
2. At the time this only work with vectorcode setup that uses a **standalone
700-
ChromaDB server**, which is not difficult to setup using docker;
699+
2. A `vectorcode.lock` file will be created in your `db_path` directory __if
700+
you're using the bundled chromadb server__. Please do not delete it while a
701+
vectorcode process is running;
701702
3. The LSP server supports `vectorise`, `query` and `ls` subcommands. The other
702703
subcommands may be added in the future.
703704
@@ -714,9 +715,7 @@ features:
714715
- `vectorise`: vectorise files into a given project.
715716
716717
To try it out, install the `vectorcode[mcp]` dependency group and the MCP server
717-
is available in the shell as `vectorcode-mcp-server`, and make sure you're using
718-
a [standalone chromadb server](#chromadb) configured in the [JSON](#configuring-vectorcode)
719-
via the `host` and `port` options.
718+
is available in the shell as `vectorcode-mcp-server`.
720719
721720
The MCP server entry point (`vectorcode-mcp-server`) provides some CLI options
722721
that you can use to customise the default behaviour of the server. To view the

docs/neovim.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -332,7 +332,7 @@ interface:
332332
| Features | `default` | `lsp` |
333333
|----------|-----------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------|
334334
| **Pros** | Fully backward compatible with minimal extra config required | Less IO overhead for loading/unloading embedding models; Progress reports. |
335-
| **Cons** | Heavy IO overhead because the embedding model and database client need to be initialised for every query. | Requires `vectorcode-server`; Only works if you're using a standalone ChromaDB server. |
335+
| **Cons** | Heavy IO overhead because the embedding model and database client need to be initialised for every query. | Requires `vectorcode-server` |
336336

337337
You may choose which backend to use by setting the [`setup`](#setupopts) option `async_backend`,
338338
and acquire the corresponding backend by the following API:

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ dependencies = [
2121
"charset-normalizer>=3.4.1",
2222
"json5",
2323
"posthog<6.0.0",
24+
"filelock>=3.15.0",
2425
]
2526
requires-python = ">=3.11,<3.14"
2627
readme = "README.md"

src/vectorcode/cli_utils.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
import json5
1414
import shtab
15+
from filelock import AsyncFileLock
1516

1617
from vectorcode import __version__
1718

@@ -610,3 +611,31 @@ def config_logging(
610611
handlers=handlers,
611612
level=level,
612613
)
614+
615+
616+
class LockManager:
617+
"""
618+
A class that manages file locks that protects the database files in daemon processes (LSP, MCP).
619+
"""
620+
621+
__locks: dict[str, AsyncFileLock]
622+
singleton: Optional["LockManager"] = None
623+
624+
def __new__(cls) -> "LockManager":
625+
if cls.singleton is None:
626+
cls.singleton = super().__new__(cls)
627+
cls.singleton.__locks = {}
628+
return cls.singleton
629+
630+
def get_lock(self, path: str | os.PathLike) -> AsyncFileLock:
631+
path = str(expand_path(str(path), True))
632+
if os.path.isdir(path):
633+
lock_file = os.path.join(path, "vectorcode.lock")
634+
logger.info(f"Creating {lock_file} for locking.")
635+
if not os.path.isfile(lock_file):
636+
with open(lock_file, mode="w") as fin:
637+
fin.write("")
638+
path = lock_file
639+
if self.__locks.get(path) is None:
640+
self.__locks[path] = AsyncFileLock(path) # pyright: ignore[reportArgumentType]
641+
return self.__locks[path]

src/vectorcode/common.py

Lines changed: 82 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import asyncio
2+
import contextlib
23
import hashlib
34
import logging
45
import os
56
import socket
67
import subprocess
78
import sys
8-
from typing import Any, AsyncGenerator
9+
from asyncio.subprocess import Process
10+
from dataclasses import dataclass
11+
from typing import Any, AsyncGenerator, Optional
912
from urllib.parse import urlparse
1013

1114
import chromadb
@@ -16,7 +19,7 @@
1619
from chromadb.config import APIVersion, Settings
1720
from chromadb.utils import embedding_functions
1821

19-
from vectorcode.cli_utils import Config, expand_path
22+
from vectorcode.cli_utils import Config, LockManager, expand_path
2023

2124
logger = logging.getLogger(name=__name__)
2225

@@ -112,32 +115,6 @@ async def start_server(configs: Config):
112115
return process
113116

114117

115-
__CLIENT_CACHE: dict[str, AsyncClientAPI] = {}
116-
117-
118-
async def get_client(configs: Config) -> AsyncClientAPI:
119-
client_entry = configs.db_url
120-
if __CLIENT_CACHE.get(client_entry) is None:
121-
settings: dict[str, Any] = {"anonymized_telemetry": False}
122-
if isinstance(configs.db_settings, dict):
123-
valid_settings = {
124-
k: v for k, v in configs.db_settings.items() if k in Settings.__fields__
125-
}
126-
settings.update(valid_settings)
127-
parsed_url = urlparse(configs.db_url)
128-
settings["chroma_server_host"] = parsed_url.hostname or "127.0.0.1"
129-
settings["chroma_server_http_port"] = parsed_url.port or 8000
130-
settings["chroma_server_ssl_enabled"] = parsed_url.scheme == "https"
131-
settings["chroma_server_api_default_path"] = parsed_url.path or APIVersion.V2
132-
settings_obj = Settings(**settings)
133-
__CLIENT_CACHE[client_entry] = await chromadb.AsyncHttpClient(
134-
settings=settings_obj,
135-
host=str(settings_obj.chroma_server_host),
136-
port=int(settings_obj.chroma_server_http_port or 8000),
137-
)
138-
return __CLIENT_CACHE[client_entry]
139-
140-
141118
def get_collection_name(full_path: str) -> str:
142119
full_path = str(expand_path(full_path, absolute=True))
143120
hasher = hashlib.sha256()
@@ -261,3 +238,80 @@ async def list_collection_files(collection: AsyncCollection) -> list[str]:
261238
or []
262239
)
263240
)
241+
242+
243+
@dataclass
244+
class _ClientModel:
245+
client: AsyncClientAPI
246+
is_bundled: bool = False
247+
process: Optional[Process] = None
248+
249+
250+
class ClientManager:
251+
singleton: Optional["ClientManager"] = None
252+
__clients: dict[str, _ClientModel]
253+
254+
def __new__(cls) -> "ClientManager":
255+
if cls.singleton is None:
256+
cls.singleton = super().__new__(cls)
257+
cls.singleton.__clients = {}
258+
return cls.singleton
259+
260+
@contextlib.asynccontextmanager
261+
async def get_client(self, configs: Config, need_lock: bool = True):
262+
project_root = str(expand_path(str(configs.project_root), True))
263+
is_bundled = False
264+
if self.__clients.get(project_root) is None:
265+
process = None
266+
if not await try_server(configs.db_url):
267+
logger.info(f"Starting a new server at {configs.db_url}")
268+
process = await start_server(configs)
269+
is_bundled = True
270+
271+
self.__clients[project_root] = _ClientModel(
272+
client=await self._create_client(configs),
273+
is_bundled=is_bundled,
274+
process=process,
275+
)
276+
lock = None
277+
if self.__clients[project_root].is_bundled and need_lock:
278+
lock = LockManager().get_lock(str(configs.db_path))
279+
logger.debug(f"Locking {configs.db_path}")
280+
await lock.acquire()
281+
yield self.__clients[project_root].client
282+
if lock is not None:
283+
logger.debug(f"Unlocking {configs.db_path}")
284+
await lock.release()
285+
286+
def get_processes(self) -> list[Process]:
287+
return [i.process for i in self.__clients.values() if i.process is not None]
288+
289+
async def kill_servers(self):
290+
termination_tasks: list[asyncio.Task] = []
291+
for p in self.get_processes():
292+
logger.info(f"Killing bundled chroma server with PID: {p.pid}")
293+
p.terminate()
294+
termination_tasks.append(asyncio.create_task(p.wait()))
295+
await asyncio.gather(*termination_tasks)
296+
297+
async def _create_client(self, configs: Config) -> AsyncClientAPI:
298+
settings: dict[str, Any] = {"anonymized_telemetry": False}
299+
if isinstance(configs.db_settings, dict):
300+
valid_settings = {
301+
k: v for k, v in configs.db_settings.items() if k in Settings.__fields__
302+
}
303+
settings.update(valid_settings)
304+
parsed_url = urlparse(configs.db_url)
305+
settings["chroma_server_host"] = parsed_url.hostname or "127.0.0.1"
306+
settings["chroma_server_http_port"] = parsed_url.port or 8000
307+
settings["chroma_server_ssl_enabled"] = parsed_url.scheme == "https"
308+
settings["chroma_server_api_default_path"] = parsed_url.path or APIVersion.V2
309+
settings_obj = Settings(**settings)
310+
return await chromadb.AsyncHttpClient(
311+
settings=settings_obj,
312+
host=str(settings_obj.chroma_server_host),
313+
port=int(settings_obj.chroma_server_http_port or 8000),
314+
)
315+
316+
def clear(self):
317+
self.__clients.clear()

0 commit comments

Comments
 (0)