mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-09-25 17:42:20 +02:00
fix(personal): route upload and delete through the index job lock
/api/personal/upload and DELETE /api/personal/file mutated the same vector and tracking state add/remove/reload serialize on, outside _index_job_lock and inline on the event loop. Both now stage async work on the loop, then run the complete transition (vector writes, disk change, personal_docs_manager update) in one offloaded critical section under the shared lock, acquired before the offload so queued requests park on the loop rather than pinning a threadpool worker. Adds add-vs-upload and add-vs-file ordering regressions.
This commit is contained in:
@@ -61,6 +61,17 @@ class _FakeRag:
|
||||
self._record["index_thread"] = threading.get_ident()
|
||||
return {"success": True, "indexed_count": 3, "failed_count": 0}
|
||||
|
||||
def _split_into_chunks(self, text, chunk_size=500):
|
||||
return [text]
|
||||
|
||||
def add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
return True
|
||||
|
||||
def delete_by_source(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
return 1
|
||||
|
||||
|
||||
class _FakeDocsManager:
|
||||
def __init__(self, record):
|
||||
@@ -71,6 +82,9 @@ class _FakeDocsManager:
|
||||
self._record["bookkeeping_thread"] = threading.get_ident()
|
||||
self._record["bookkeeping_index_flag"] = index
|
||||
|
||||
def exclude_file(self, filepath):
|
||||
self._record["exclude_thread"] = threading.get_ident()
|
||||
|
||||
|
||||
def _build_app(tmp_path, monkeypatch, record):
|
||||
monkeypatch.setattr(personal_routes, "PERSONAL_DIR", str(tmp_path))
|
||||
@@ -219,6 +233,94 @@ async def test_add_and_remove_serialize(tmp_path, monkeypatch):
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_upload_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: POST /upload writes chunks into the vector store and then
|
||||
calls personal_docs_manager.add_directory — the same vector/tracking state
|
||||
add_directory mutates. It must hold the SAME job lock, or an upload landing
|
||||
mid-add interleaves two writers over unsynchronized state."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_add_document(self, chunk, metadata):
|
||||
self._record["add_document_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return True
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "add_document", _slow_add_document)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
monkeypatch.setattr(personal_routes, "require_privilege", lambda request, key: "tester")
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.post("/api/personal/upload", files={"files": ("a.txt", b"hello world", "text/plain")}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
# The test coroutine runs on the event loop, so this IS the loop thread.
|
||||
assert record["add_document_thread"] != threading.get_ident(), (
|
||||
"rag.add_document ran on the event loop thread — chunk writes block "
|
||||
"every other request for the duration of the upload"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/upload critical sections overlapped — "
|
||||
"upload must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_and_delete_file_serialize(tmp_path, monkeypatch):
|
||||
"""#5634 follow-up: DELETE /file removes chunks from the vector store and
|
||||
calls personal_docs_manager.exclude_file. Both mutate state add_directory
|
||||
also touches, so the delete must hold the SAME job lock as add."""
|
||||
import time
|
||||
|
||||
state, enter, leave = _serialization_probe()
|
||||
|
||||
def _slow_index(self, directory, owner=None):
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return {"success": True, "indexed_count": 1, "failed_count": 0}
|
||||
|
||||
def _slow_delete(self, filepath):
|
||||
self._record["delete_thread"] = threading.get_ident()
|
||||
enter(); time.sleep(0.25); leave()
|
||||
return 1
|
||||
|
||||
monkeypatch.setattr(_FakeRag, "index_personal_documents", _slow_index)
|
||||
monkeypatch.setattr(_FakeRag, "delete_by_source", _slow_delete)
|
||||
|
||||
record = {}
|
||||
app = _build_app(tmp_path, monkeypatch, record)
|
||||
monkeypatch.setattr(personal_routes, "UPLOADS_DIR", str(tmp_path / "uploads"))
|
||||
(tmp_path / "docs_a").mkdir()
|
||||
doomed = tmp_path / "doomed.txt"
|
||||
doomed.write_text("bye")
|
||||
|
||||
async with _async_client(app) as ac:
|
||||
results = await asyncio.gather(
|
||||
ac.post("/api/personal/add_directory", json={"directory": str(tmp_path / "docs_a")}),
|
||||
ac.delete("/api/personal/file", params={"filepath": str(doomed)}),
|
||||
)
|
||||
|
||||
assert all(r.status_code == 200 for r in results)
|
||||
assert record["delete_thread"] != threading.get_ident(), (
|
||||
"rag.delete_by_source ran on the event loop thread"
|
||||
)
|
||||
assert state["max_active"] == 1, (
|
||||
f"{state['max_active']} add/delete critical sections overlapped — "
|
||||
"delete must hold the same index job lock as add"
|
||||
)
|
||||
|
||||
|
||||
async def test_reload_serializes_with_add(tmp_path, monkeypatch):
|
||||
"""#5634: POST /reload rebuilds the index via refresh_index(); it must hold
|
||||
the same job lock so it cannot race an in-flight add job."""
|
||||
|
||||
Reference in New Issue
Block a user