diff --git a/.claude/scheduled_tasks.lock b/.claude/scheduled_tasks.lock deleted file mode 100644 index 4e015a00..00000000 --- a/.claude/scheduled_tasks.lock +++ /dev/null @@ -1 +0,0 @@ -{"sessionId":"fdfa73f5-8734-459a-8f23-67e1eeaa804c","pid":13329,"procStart":"23713","acquiredAt":1780838157352} \ No newline at end of file diff --git a/.gitignore b/.gitignore index d527b495..469e4d90 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,9 @@ __pycache__/ settings.toml settings.local.toml +# Claude Code runtime artifacts (the rest of .claude/ is tracked) +.claude/scheduled_tasks.lock + # Git worktrees/ diff --git a/nextcloud_mcp_server/alembic/versions/20260610_1200_007_add_usage_events.py b/nextcloud_mcp_server/alembic/versions/20260610_1200_007_add_usage_events.py index 17c38d40..fcfefa45 100644 --- a/nextcloud_mcp_server/alembic/versions/20260610_1200_007_add_usage_events.py +++ b/nextcloud_mcp_server/alembic/versions/20260610_1200_007_add_usage_events.py @@ -34,6 +34,9 @@ depends_on = None def upgrade() -> None: is_pg = op.get_bind().dialect.name == "postgresql" + # Retention: this table has no TTL by design — the control-plane rollup + # owns the lifecycle (it pulls rows read-only into usage_daily, then + # prunes once a day is reconciled). The data plane only appends. op.create_table( "usage_events", # Pod-generated idempotency key. UUID on Postgres; TEXT on SQLite, diff --git a/nextcloud_mcp_server/server/semantic.py b/nextcloud_mcp_server/server/semantic.py index 9640573d..4b3574f3 100644 --- a/nextcloud_mcp_server/server/semantic.py +++ b/nextcloud_mcp_server/server/semantic.py @@ -534,9 +534,11 @@ def configure_semantic_tools(mcp: FastMCP): "fusion": fusion, "doc_types": doc_types, }, - # Pass the already-resolved flag so the store doesn't - # rebuild Settings on this hot query path (ADR-024). - enabled=settings.usage_metering_enabled, + # The outer guard already confirmed the flag, so pass + # enabled=True directly — the store then skips a second + # uncached Settings build on this hot query path + # (ADR-024). + enabled=True, ) except Exception: # Reached only when shared()/store construction itself diff --git a/nextcloud_mcp_server/usage/store.py b/nextcloud_mcp_server/usage/store.py index c7551b61..b2298e78 100644 --- a/nextcloud_mcp_server/usage/store.py +++ b/nextcloud_mcp_server/usage/store.py @@ -51,18 +51,27 @@ _INSERT_SQL = ( class UsageEventStore: """Append-only writer for the app-DB ``usage_events`` table.""" + # Process-wide cached instance returned by ``shared()`` so the hot search + # path doesn't allocate a fresh wrapper per metered query. The store is + # stateless beyond its storage handle, so one instance is reusable. + _shared_instance: "UsageEventStore | None" = None + def __init__(self, storage: RefreshTokenStorage) -> None: self._storage = storage @classmethod async def shared(cls) -> "UsageEventStore": - """Build a store backed by the process-wide storage singleton. + """Return the process-wide store backed by the storage singleton. - ``get_shared_storage()`` runs ``initialize()`` (and thus Alembic - migrations) on first access, so the ``usage_events`` table is present - by the time any event is recorded. + Cached after first build: ``get_shared_storage()`` already returns the + cached :class:`RefreshTokenStorage` (running ``initialize()`` / Alembic + on first access, so ``usage_events`` exists), and the wrapper itself is + stateless, so reusing one instance avoids a per-call allocation on the + ``nc_semantic_search`` hot path. """ - return cls(await get_shared_storage()) + if cls._shared_instance is None: + cls._shared_instance = cls(await get_shared_storage()) + return cls._shared_instance async def record_usage_event( self, diff --git a/nextcloud_mcp_server/vector/processor.py b/nextcloud_mcp_server/vector/processor.py index 0565cd75..87e94a6d 100644 --- a/nextcloud_mcp_server/vector/processor.py +++ b/nextcloud_mcp_server/vector/processor.py @@ -839,9 +839,10 @@ async def _index_document( "user_id": doc_task.user_id, "total_chars": total_chars, }, - # Pass the already-resolved flag so the store doesn't - # rebuild Settings here (ADR-024). - enabled=settings.usage_metering_enabled, + # The outer guard already confirmed the flag, so pass + # enabled=True directly — the store then skips a second + # uncached Settings build here (ADR-024). + enabled=True, ) except Exception: # Reached only when shared()/store construction itself diff --git a/tests/unit/test_usage_store.py b/tests/unit/test_usage_store.py index 11ab5083..e15222a8 100644 --- a/tests/unit/test_usage_store.py +++ b/tests/unit/test_usage_store.py @@ -12,6 +12,7 @@ that a DB failure is swallowed instead of surfacing to the caller. """ import json +import logging import tempfile import uuid from datetime import datetime, timezone @@ -199,7 +200,7 @@ async def test_metadata_none_is_null(storage, monkeypatch): assert row[4] is None -async def test_best_effort_swallows_db_errors(storage, monkeypatch): +async def test_best_effort_swallows_db_errors(storage, monkeypatch, caplog): """A DB failure is logged + dropped, never raised into the caller.""" _set_metering(monkeypatch, True) store = UsageEventStore(storage) @@ -218,10 +219,16 @@ async def test_best_effort_swallows_db_errors(storage, monkeypatch): monkeypatch.setattr(storage, "acquire", _boom) # Must not raise. - await store.record_usage_event(metric="pages_chunks", value=1) + with caplog.at_level(logging.WARNING, logger="nextcloud_mcp_server.usage.store"): + await store.record_usage_event(metric="pages_chunks", value=1) assert recorded, "record_db_operation should be called on the error path" assert recorded[-1][3] == "error" + # The observability contract: the dropped write surfaces at WARNING. + assert any( + r.levelno == logging.WARNING and "usage metering write dropped" in r.message + for r in caplog.records + ) async def test_best_effort_swallows_unserializable_metadata(storage, monkeypatch):