fix(search-index): bisect ops, thread-safe mutation bridge, drop edge upserts

- Replace list.sort() on every upsert with bisect.insort() — O(log n) per
  insert instead of O(n log n); bulk rebuild still sorts once at the end
- Replace list.remove() in remove() with bisect.bisect_left + pop() — O(log n)
  find instead of O(n) scan
- Wrap handle_graph_mutation() index mutations in self._lock — mutation bridge
  fires from a background thread and was racing concurrent search/rebuild calls
- Drop source/target upserts in add_edge() — edges don't change node text so
  the index documents are identical; removes unnecessary cache invalidation
- Sort tag values in _cache_key() — ["a","b"] and ["b","a"] now share a cache
  entry since _passes_filters() uses set intersection (order-independent)
- Restore @app.get("/") root handler missing from this branch vs main

Co-authored-by: ZohaibHassan16 <zohaibhassan16@users.noreply.github.com>
Co-authored-by: KaifAhmad1 <mohammadk78600@gmail.com>
This commit is contained in:
KaifAhmad1
2026-04-19 18:07:42 +05:30
co-authored by ZohaibHassan16 KaifAhmad1
parent 073c48882c
commit d22a54353a
3 changed files with 23 additions and 18 deletions
+12 -1
View File
@@ -10,7 +10,7 @@ from typing import Optional
from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, JSONResponse
from fastapi.responses import FileResponse, HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from .. import __version__
@@ -144,6 +144,17 @@ def create_app(session: Optional[GraphSession] = None) -> FastAPI:
"status": "active",
}
@app.get("/", include_in_schema=False)
async def root():
index_path = Path(__file__).resolve().parent.parent / "static" / "index.html"
if index_path.is_file():
return FileResponse(index_path)
return HTMLResponse(
'<!doctype html><html lang="en"><head><meta charset="UTF-8">'
'<title>Semantica Knowledge Explorer</title></head>'
'<body><div id="root"></div></body></html>'
)
static_dir = Path(__file__).resolve().parent.parent / "static"
if static_dir.is_dir():
assets_dir = static_dir / "assets"
+7 -8
View File
@@ -4,6 +4,7 @@ Explorer-local in-memory node search index.
from __future__ import annotations
import bisect
import heapq
import re
from collections import OrderedDict, defaultdict
@@ -155,10 +156,9 @@ class GraphSearchIndex:
if not prefix_bucket:
self._prefix_index.pop(prefix, None)
try:
self._ordered_node_ids.remove(node_id)
except ValueError:
pass
pos = bisect.bisect_left(self._ordered_node_ids, node_id)
if pos < len(self._ordered_node_ids) and self._ordered_node_ids[pos] == node_id:
self._ordered_node_ids.pop(pos)
if clear_cache:
self.clear_cache()
@@ -180,9 +180,8 @@ class GraphSearchIndex:
for length in range(self.prefix_min_length, min(len(token), self.prefix_max_length) + 1):
self._prefix_index[token[:length]].add(node_id)
if node_id not in self._ordered_node_ids:
self._ordered_node_ids.append(node_id)
self._ordered_node_ids.sort()
if node_id not in self._documents:
bisect.insort(self._ordered_node_ids, node_id)
if clear_cache:
self.clear_cache()
@@ -338,7 +337,7 @@ class GraphSearchIndex:
for key in sorted(filters.keys()):
value = filters[key]
if isinstance(value, (list, tuple, set)):
serialized_filters.append((key, tuple(str(item) for item in value)))
serialized_filters.append((key, tuple(sorted(str(item) for item in value))))
else:
serialized_filters.append((key, str(value)))
return normalized_query, limit, tuple(serialized_filters)
+4 -9
View File
@@ -411,9 +411,11 @@ class GraphSession:
if normalized_event in {"ADD_NODE", "UPDATE_NODE"}:
normalized_node = self.normalize_node(payload or {})
if normalized_node.get("id"):
self._search_index.upsert(normalized_node)
with self._lock:
self._search_index.upsert(normalized_node)
elif normalized_event in {"REMOVE_NODE", "DELETE_NODE"}:
self._search_index.remove(str(entity_id))
with self._lock:
self._search_index.remove(str(entity_id))
elif normalized_event in {"RELOAD_GRAPH", "RESET_GRAPH"}:
self.rebuild_search_index()
@@ -638,11 +640,4 @@ class GraphSession:
**properties,
)
has_mutation_callback = callable(getattr(self.graph, "mutation_callback", None))
if added and not has_mutation_callback:
source = self.get_node(source_id)
if source is not None:
self._search_index.upsert(source)
target = self.get_node(target_id)
if target is not None:
self._search_index.upsert(target)
return added