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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed
- Web UI no longer 500s under its own poll ([#130](https://github.com/CryptoJones/omind/issues/130)): `OmiStore`'s summary cache is guarded by a lock, so concurrent `list_notes()` calls from FastAPI's threadpool can't hit "dictionary changed size during iteration". The MCP server also caches the `[[wikilink]]` graph build (invalidated by a cheap vault signature), so a burst of graph-tool queries costs one full-vault parse instead of one per tool.

### Changed
- CI now tests on macOS and builds + smoke-tests the wheel ([#126](https://github.com/CryptoJones/omind/issues/126)): a `macos-latest` matrix leg (oldest + newest Python) so BSD-userland / case-insensitive-FS / PATH breakage can't ship green, and a `wheel` job that builds the real wheel, installs it non-editable, and asserts the packaged hook scripts and `web/static` assets are present (the editable install never exercised the wheel's file-selection).
- Pin dependency upper bounds and install the fleet by release tag ([#131](https://github.com/CryptoJones/omind/issues/131)): runtime deps are capped below the next major (`fastapi<1.0`, `mcp<2.0`, …) so a breaking upstream major can't land fleet-wide overnight through `uv tool install` (which ignores `uv.lock`), and `scripts/bootstrap.sh` now installs the latest published release tag by default instead of the moving `main` HEAD (override with `--ref`/`$OMIND_REF`).
Expand Down
37 changes: 32 additions & 5 deletions src/omind/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

from __future__ import annotations

import contextlib
import logging
import sys
from pathlib import Path
Expand Down Expand Up @@ -60,6 +61,32 @@ def build_server(omi_dir: Path | str, node_id: str | None = None) -> FastMCP:

mcp = FastMCP(SERVER_NAME, instructions=_INSTRUCTIONS)

# The five graph tools each rebuilt the whole [[wikilink]] graph from disk
# (a full-vault read+parse) on every call. Cache it, invalidated by a cheap
# signature over the note files' (count, total size, newest mtime) — so a
# burst of graph queries costs one parse, and any write busts the cache.
graph_cache: dict[str, object] = {}

def _vault_signature() -> tuple[int, int, int]:
count = size = mtime = 0
with contextlib.suppress(OSError):
for p in store.omi_dir.glob("*.md"):
try:
st = p.stat()
except OSError:
continue
count += 1
size += st.st_size
mtime = max(mtime, st.st_mtime_ns)
return (count, size, mtime)

def graph_for() -> graph.Graph:
sig = _vault_signature()
if graph_cache.get("sig") != sig:
graph_cache["sig"] = sig
graph_cache["graph"] = graph.build_graph(store.omi_dir)
return graph_cache["graph"] # type: ignore[return-value]

@mcp.tool(
name="read-note",
description=(
Expand Down Expand Up @@ -204,7 +231,7 @@ def list_tags() -> list[str]:
def graph_neighbors(
name: str, depth: int = 1, direction: str = "both"
) -> list[dict[str, object]]:
g = graph.build_graph(store.omi_dir)
g = graph_for()
return [
{"filename": filename, "distance": distance}
for filename, distance in graph.neighbors(
Expand All @@ -220,7 +247,7 @@ def graph_neighbors(
),
)
def graph_path(source: str, target: str) -> dict[str, object]:
g = graph.build_graph(store.omi_dir)
g = graph_for()
return {"path": graph.shortest_path(g, source, target)}

@mcp.tool(
Expand All @@ -230,7 +257,7 @@ def graph_path(source: str, target: str) -> dict[str, object]:
),
)
def graph_orphans() -> list[str]:
return graph.orphans(graph.build_graph(store.omi_dir))
return graph.orphans(graph_for())

@mcp.tool(
name="graph-dangling",
Expand All @@ -239,15 +266,15 @@ def graph_orphans() -> list[str]:
),
)
def graph_dangling() -> list[dict[str, str]]:
g = graph.build_graph(store.omi_dir)
g = graph_for()
return [{"source": src, "target": target} for src, target in graph.dangling_links(g)]

@mcp.tool(
name="graph-stats",
description="Whole-graph counts: notes, links, orphans, and dangling links.",
)
def graph_stats() -> dict[str, int]:
return graph.stats(graph.build_graph(store.omi_dir))
return graph.stats(graph_for())

return mcp

Expand Down
22 changes: 16 additions & 6 deletions src/omind/store.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import os
import re
import tempfile
import threading
from collections.abc import Callable, Iterator
from dataclasses import asdict, dataclass, field
from datetime import date
Expand Down Expand Up @@ -619,6 +620,11 @@ def __init__(self, omi_dir: Path | str, node_id: str | None = None) -> None:
self._node_id_resolved = node_id is not None
# Listing cache: filename -> ((st_mtime_ns, st_size), NoteSummary).
self._summary_cache: dict[str, tuple[tuple[int, int], NoteSummary]] = {}
# Guards the summary cache. The store was written for a single-threaded
# MCP loop, but the web app runs sync endpoints on FastAPI's threadpool,
# so concurrent list_notes() calls mutated the cache while another thread
# iterated it -> "dictionary changed size during iteration" 500s.
self._cache_lock = threading.Lock()

@property
def node_id(self) -> str | None:
Expand Down Expand Up @@ -760,11 +766,13 @@ def _cached_summary(self, path: Path) -> NoteSummary | None:
except OSError:
return None
key = (st.st_mtime_ns, st.st_size)
hit = self._summary_cache.get(path.name)
with self._cache_lock:
hit = self._summary_cache.get(path.name)
if hit is not None and hit[0] == key:
return hit[1]
summary = self._summarize(path)
self._summary_cache[path.name] = (key, summary)
summary = self._summarize(path) # file I/O outside the lock
with self._cache_lock:
self._summary_cache[path.name] = (key, summary)
return summary

def _summarize_fields(self, path: Path, fields: NoteFields) -> NoteSummary:
Expand All @@ -788,9 +796,11 @@ def list_notes(self, include_disabled: bool = False) -> list[NoteSummary]:
seen.add(p.name)
if include_disabled or not s.disabled:
summaries.append(s)
# Drop cache entries for notes that no longer exist on disk.
for stale in [name for name in self._summary_cache if name not in seen]:
del self._summary_cache[stale]
# Drop cache entries for notes that no longer exist on disk. Under the
# lock so a concurrent _cached_summary insert can't race the iteration.
with self._cache_lock:
for stale in [name for name in self._summary_cache if name not in seen]:
del self._summary_cache[stale]
summaries.sort(key=lambda s: (s.created or "", s.title.lower()), reverse=True)
return summaries

Expand Down
23 changes: 23 additions & 0 deletions tests/test_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,29 @@ def test_graph_tools(server: FastMCP) -> None:
assert call(server, "graph-stats", {})["notes"] == 4


def test_graph_build_is_cached_and_busted_by_a_write(omi_dir: Path, monkeypatch) -> None: # type: ignore[no-untyped-def]
"""The 5 graph tools reuse one cached build; a write busts the cache (#130)."""
from omind import graph as graph_mod

calls = {"n": 0}
real = graph_mod.build_graph

def counting(omi: Path) -> Any:
calls["n"] += 1
return real(omi)

monkeypatch.setattr(graph_mod, "build_graph", counting)
server = build_server(omi_dir, node_id="testnode-abc123")
call(server, "create-note", {"title": "A", "connections": ["B"]})
call(server, "graph-stats", {})
call(server, "graph-orphans", {})
call(server, "graph-dangling", {})
assert calls["n"] == 1 # three graph queries, one build (cached)
call(server, "create-note", {"title": "B"}) # a write changes the vault
call(server, "graph-stats", {})
assert calls["n"] == 2 # cache busted, rebuilt once


def test_graph_neighbors_unknown_note_is_a_tool_error(server: FastMCP) -> None:
with pytest.raises(ToolError, match="not found"):
call(server, "graph-neighbors", {"name": "Nope"})
Expand Down
25 changes: 25 additions & 0 deletions tests/test_store.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,6 +776,31 @@ def test_non_utf8_note_does_not_break_listing(store: OmiStore) -> None:
assert store.search("fine") # search still works


def test_list_notes_is_threadsafe_under_concurrency(store: OmiStore) -> None:
"""The web app runs list_notes() on FastAPI's threadpool; concurrent calls
must not raise 'dictionary changed size during iteration' (#130)."""
import threading

for i in range(40):
store.create_note(NoteFields(title=f"Note {i:02d}", summary="s"))
errors: list[Exception] = []

def hammer() -> None:
try:
for _ in range(60):
store.list_notes()
store.list_notes(include_disabled=True)
except Exception as exc: # noqa: BLE001 — the point is to catch the race
errors.append(exc)

threads = [threading.Thread(target=hammer) for _ in range(8)]
for t in threads:
t.start()
for t in threads:
t.join()
assert not errors, errors[:3]


def test_disable_ignores_disabled_bullet_in_details(mesh_store: OmiStore) -> None:
"""A '- Disabled: true' line in Details must not fool disable/parse (Metadata-scoped)."""
name = mesh_store.create_note(
Expand Down
Loading