feat: add HttpChromaBackend + Postgres KG backend for stateless deployments - #1337
feat: add HttpChromaBackend + Postgres KG backend for stateless deployments#1337shockstricken wants to merge 6 commits into
Conversation
Refactor the temporal knowledge graph so it works against either local SQLite (the historical implementation) or a remote Postgres instance. This is the first half of the "stateless palace" effort: drawer/closet data lives in ChromaDB which already has a backend abstraction; the KG was the remaining local-only state. Now both can be externalized. Layout ------ * ``mempalace/kg/base.py`` — ``BaseKnowledgeGraph`` ABC with the historical 8-method surface (``add_entity``, ``add_triple``, ``invalidate``, ``query_entity``, ``query_relationship``, ``timeline``, ``stats``, ``seed_from_entity_facts``, ``close``). * ``mempalace/kg/sqlite.py`` — ``SqliteKnowledgeGraph``: the original ``KnowledgeGraph`` implementation lifted verbatim behind the ABC. Behavior is byte-for-byte unchanged. * ``mempalace/kg/postgres.py`` — ``PostgresKnowledgeGraph``: SQLAlchemy 2.x Core, dialect-native ``ON CONFLICT`` upserts, JSONB on Postgres / JSON elsewhere via a custom ``TypeDecorator``. * ``mempalace/kg/_schema.py`` — single ``MetaData`` instance shared between in-process ``create_all`` and future Alembic migrations. * ``mempalace/kg/factory.py`` — ``KnowledgeGraph(...)`` is now a factory that picks the right backend based on its arguments and the ``MEMPALACE_DATABASE_URL`` env var. * ``mempalace/knowledge_graph.py`` — backwards-compat shim re-exporting the factory and ``DEFAULT_KG_PATH``. Existing ``from mempalace.knowledge_graph import KnowledgeGraph`` callers keep working without modification. Selection priority (first non-empty wins): 1. Explicit ``url=`` keyword argument 2. ``MEMPALACE_DATABASE_URL`` env var 3. Explicit ``db_path=`` keyword argument 4. ``MEMPALACE_KG_PATH`` env var 5. Default ``~/.mempalace/knowledge_graph.sqlite3`` (SQLite) Tests ----- * New ``tests/test_kg_conformance.py`` parametrizes the same behavioral test set across SQLite, SQLAlchemy-on-SQLite (catches SQLAlchemy-layer bugs without docker), and Postgres-via-testcontainers (skipped when ``testcontainers`` is unavailable; tagged with ``@pytest.mark.postgres``). * ``tests/test_kg_thread_safety.py`` updated to assert against ``SqliteKnowledgeGraph.close`` since ``KnowledgeGraph`` is now a factory function. * ``tests/test_readme_claims.py`` URL-decodes the version-badge value before comparing, since shields.io requires ``+`` to be ``%2B`` in PEP 440 local-version segments. All 1492 existing tests pass on this branch with the new shim; 99 KG-related tests pass (28 historical + 13×3 conformance + version/ thread-safety) with 13 postgres-marked tests skipped without docker. Packaging --------- * Bump version to ``3.3.4+stateless.1`` (PEP 440 local-version segment — preserves upstream-version visibility for Renovate while marking fork-only changes). * New ``[postgres]`` and ``[remote]`` extras pull in ``sqlalchemy>=2``, ``psycopg[binary]>=3.2``, ``alembic>=1.13``. * Pre-register ``chroma_http`` entry point in ``mempalace.backends`` for the next phase (HTTP-mode ChromaDB backend).
Implement the ``chroma_http`` backend so MemPalace can run against an external ChromaDB server (``chromadb run`` or a managed service) instead of an embedded ``PersistentClient``. Lays the groundwork for truly stateless palace pods. Design ------ * ``mempalace/backends/chroma_http.py`` — new ``HttpChromaBackend`` extending ``BaseBackend`` (RFC 001 §2). Owns a single cached ``chromadb.HttpClient`` per process. Multiple palaces share the same chromadb server but use distinct collection-name prefixes derived from ``PalaceRef.namespace`` (falling back to the palace ``id``). * Reuses the existing ``ChromaCollection`` adapter from ``backends/chroma.py`` — the per-collection wire format is identical whether the underlying client is ``PersistentClient`` or ``HttpClient``. * HTTP mode skips every filesystem-coupled operation: no ``quarantine_stale_hnsw``, no ``_fix_blob_seq_ids``, no ``os.makedirs`` on the palace path. Those operations target ChromaDB's internal sqlite/HNSW state, which physically lives on the remote chromadb server in HTTP mode. * ``_qualify`` sanitizes the collection-name prefix to chromadb's ``[a-zA-Z0-9._-]`` rule and ensures the first character is alphanumeric (``p`` prefix when needed). Configuration ------------- All env-driven, all optional (constructor accepts each as a kwarg for tests): * ``MEMPALACE_CHROMA_URL`` — full URL, preferred form. * ``MEMPALACE_CHROMA_HOST`` / ``MEMPALACE_CHROMA_PORT`` / ``MEMPALACE_CHROMA_SSL`` — split form. * ``MEMPALACE_CHROMA_AUTH_TOKEN`` / ``MEMPALACE_CHROMA_AUTH_HEADER`` — bearer-token auth, header name defaults to ``Authorization`` but can override (``X-Api-Key`` for proxies). * ``MEMPALACE_CHROMA_TENANT`` / ``MEMPALACE_CHROMA_DATABASE`` — multi-tenant chromadb routing. ``HttpChromaBackend.detect()`` returns True iff any URL/HOST/PORT env var is set, so :func:`registry.resolve_backend_for_palace` picks HTTP mode automatically when configured. Registry + entry point ---------------------- * ``mempalace/backends/registry.py::_register_builtins`` — register the new backend alongside ``chroma``. * ``pyproject.toml`` already declares the ``chroma_http`` entry point under ``mempalace.backends`` (added in the previous commit for forward compat); now it's pointing at a real class. Tests ----- * ``tests/test_chroma_http_backend.py`` — 28 unit tests covering URL parsing, env resolution, namespace qualification, registry membership, ``detect()`` behavior, ``close()`` idempotence. * Integration round-trip test gated behind ``@pytest.mark.chroma_http`` — runs against ``CHROMADB_TEST_URL`` if set, otherwise spins up a ``chromadb/chroma:0.5.20`` testcontainer. Skipped without docker.
Stateless mempalace pods need to talk to a centralized ChromaDB server (``chromadb run`` / managed) instead of an embedded ``PersistentClient``. This adds the second half of the "stateless palace" effort: drawer and closet collections live on the remote server, and the per-pod PVC becomes optional. Architecture ------------ * ``HttpChromaBackend`` extends ``BaseBackend`` exactly the same shape as ``ChromaBackend``, so callers using the kwargs-only ``palace=PalaceRef`` contract from RFC 001 work unchanged. * The ``ChromaCollection`` adapter is reused as-is — it wraps a ``chromadb.Collection`` object, which is identical between ``PersistentClient`` and ``HttpClient``. Zero duplication. * Multiple palaces can share one chromadb server; collection names are prefixed with ``palace_ref.namespace`` (or ``palace_ref.id`` if no namespace is set), sanitized to chromadb's allowed character class. * Capability set advertises ``http_mode`` (replacing ``local_mode``) so ``searcher.py`` and ``repair.py`` can gate the local-only fallback paths in a follow-up commit. Configuration env vars (all optional, all overridable from the constructor): * ``MEMPALACE_CHROMA_URL`` — full URL, preferred form. * ``MEMPALACE_CHROMA_HOST``, ``_PORT``, ``_SSL`` — split form. * ``MEMPALACE_CHROMA_AUTH_TOKEN`` (+ ``_AUTH_HEADER`` to override ``Authorization`` for proxies that prefer ``X-Api-Key``). * ``MEMPALACE_CHROMA_TENANT``, ``_DATABASE`` — chroma multi-tenant routing. What's deliberately NOT in this commit -------------------------------------- * Wiring ``mcp_server._get_client`` to use this backend — that lives in the config-selection commit so the wiring decision is a single place. * Refusing local-only operations (``mempalace repair-status``, ``--mode max-seq-id``) when running in HTTP mode — that's the next commit (searcher/repair backend-aware refactor). Tests ----- * ``tests/test_chroma_http_backend.py`` — 28 unit tests with ``chromadb.HttpClient`` mocked. Covers env resolution, URL parsing, connection caching, namespace prefixing, sanitization, health probe paths, registry integration, and ``make_client`` legacy shim. * Full repo test suite still green (1521 passed, 14 skipped) after registering ``chroma_http`` as a built-in backend. * New pytest marker ``chroma_http`` reserved for the live-server integration tests that ship in the next commit.
Splits the BM25-only candidate path into two implementations chosen at
runtime by the active chromadb backend:
* ``_bm25_only_via_sqlite`` — unchanged. Reads ``chroma.sqlite3`` +
``embedding_fulltext_search`` directly. Active when local backend.
* ``_bm25_only_via_api`` — new. Uses ``collection.get(where_document=
{"$contains": tok}, where=metadata, ...)`` for candidate selection
and BM25-ranks the materialized docs in Python. Active when HTTP
backend (no client-side sqlite). Same return shape as the sqlite
path so ``_merge_bm25_union_candidates`` and ``search_memories``
don't have to special-case.
The ``_bm25_only`` dispatcher is the new single entry point; both
historical callers (``search_memories(vector_disabled=True)`` and
``_merge_bm25_union_candidates``) updated.
``mempalace/_runtime.py`` (new) centralizes the backend-mode question
behind ``using_local_chroma()`` / ``using_http_chroma()`` with a
process-local cache and a ``reset_backend_mode_cache()`` for tests.
This keeps the routing decision in one place — searcher today, repair
and the MCP server's HNSW capacity probe in follow-ups.
Tests
-----
* All 65 existing searcher + hybrid-candidate-union tests still pass.
* The HTTP-mode ``_bm25_only_via_api`` round-trip test will land with
the chroma_http integration test (``@pytest.mark.chroma_http``).
The remaining call sites that read ``chroma.sqlite3`` or
``index_metadata.pickle`` directly (BM25 fallback, HNSW capacity probe,
``mempalace repair``) needed to learn whether the active backend is
local or HTTP. Add a single source of truth and wire every consumer
through it.
* ``mempalace/_runtime.py`` — new module. ``resolve_backend_mode()``,
``using_local_chroma()``, ``using_http_chroma()`` resolve from
``MEMPALACE_BACKEND`` (explicit) or ``MEMPALACE_CHROMA_*`` env vars
(auto). Cached per process, ``reset_backend_mode_cache()`` for tests.
* ``mempalace/palace.py::_resolve_backend()`` — ``get_collection()``
now picks ``ChromaBackend`` or ``HttpChromaBackend`` per call. The
HTTP backend instance is cached so the chromadb ``HttpClient``
connection pool is shared across the searcher/miner/MCP paths.
* ``mempalace/searcher.py``:
* New ``_bm25_only(...)`` dispatcher routes to either the existing
``_bm25_only_via_sqlite`` (local) or the new
``_bm25_only_via_api`` (HTTP).
* ``_bm25_only_via_api`` implements the BM25-only fallback using
chromadb's public API: ``where_document={"$contains": tok}`` per
query token (≥3 chars to mirror the FTS5 trigram tokenizer),
union the results, BM25-rank in Python, recency-fallback when
no token survives. Same result shape as the sqlite path so
``_merge_bm25_union_candidates`` and ``search_memories``
consume it unchanged.
* ``_merge_bm25_union_candidates`` and the ``vector_disabled`` branch
of ``search_memories`` now call the dispatcher.
* ``mempalace/mcp_server.py``:
* ``_get_client()`` builds an ``HttpClient`` once (no inode/mtime
watching) when in HTTP mode; falls through to the existing
``PersistentClient`` cache for local mode.
* ``_refresh_vector_disabled_flag()`` is a no-op in HTTP mode
(segment health is server-side; client never sees divergence).
* ``mempalace/repair.py``:
* New ``BackendUnsupportedError`` for clean refusals.
* ``status()`` returns ``status="unsupported"`` and prints the HTTP
refusal hint instead of probing local files.
* ``rebuild_index()`` swaps the sqlite-count cross-check
(MemPalace#1208 guard) for a ``collection.count()`` cross-check in HTTP
mode, skips the ``chroma.sqlite3`` backup, and surfaces the
"snapshot the chromadb server before rebuild" warning.
* ``repair_max_seq_id()`` early-exits with a clear message — the
table lives in the chromadb server's internal sqlite and has
no remote API.
* ``mempalace/cli.py::cmd_repair`` — HTTP mode delegates to
``repair.rebuild_index`` directly, skipping the CLI's own palace-dir
copytree backup (which requires local filesystem access).
Result
------
Every end-user feature path (mine, search, KG, all MCP tools, scan,
prune, rebuild) works against either backend. Two operator-only
diagnostic ops (``repair-status``, ``repair --mode max-seq-id``)
refuse cleanly in HTTP mode with a hint pointing at the chromadb
server — those manipulate ChromaDB's *internal* storage, which is
physically inaccessible from a remote client.
1521 tests pass on this branch, no regressions vs. main.
Surface the new env vars (``MEMPALACE_DATABASE_URL``, ``MEMPALACE_CHROMA_URL`` / ``_HOST`` / ``_PORT`` / ``_SSL`` / ``_TENANT`` / ``_DATABASE``) as properties on ``MempalaceConfig`` and add ``backend_mode`` so a single config object answers both "where is my palace?" and "which backend will I use?" without callers cross-importing :mod:`mempalace._runtime`. Also adds the legacy ``delete_collection`` / ``create_collection`` helpers on :class:`HttpChromaBackend` so :func:`repair.rebuild_index` works against either backend through a uniform interface.
|
Architectural-neighbor signal: the jphein/mempalace fork is exploring a different stateless-deployment substrate that lands the storage layer in Postgres entirely — pgvector for embeddings (composing on @skuznetsov's open #665) and Apache AGE for the knowledge graph (graph as a co-located Postgres extension rather than a sibling Postgres database). The two approaches answer different questions: this PR's HttpChromaBackend says "ChromaDB stays the vector store; make the storage layer remote so pods can move freely." Our fork's pgvector + AGE says "consolidate everything into one Postgres engine — single connection, single backup story, KG traversals adjacent to vector similarity scores." Both respect the RFC 001 backend contract, which means they don't collide at the abstraction layer. A deployment could in principle pick the right backend for its operational shape — stateless-with-Chroma-HTTP for ephemeral cloud pods, monolithic-Postgres for homelab-shaped boxes — and the rest of mempalace stays unchanged. Spec for the pgvector + AGE direction is at |
Context
This PR adds support for deploying MemPalace as a fully stateless workload: external ChromaDB (HTTP) for drawer/closet storage and Postgres for the knowledge graph, with no local PVC. Currently both stores must live on the same filesystem as the mempalace process, which prevents single-replica StatefulSet pods from being moved without dragging gigabytes of palace state along.
The implementation respects the existing RFC 001 backend abstraction (
mempalace.backends.{base,registry,chroma}) and is additive — defaults are unchanged, all 1521 existing tests pass on this branch with no regressions.What it adds
KG abstraction layer (
mempalace.kg)BaseKnowledgeGraphABC mirrors the historicalKnowledgeGraph8-method surface (add_entity,add_triple,invalidate,query_entity,query_relationship,timeline,stats,seed_from_entity_facts,close).SqliteKnowledgeGraph— original implementation lifted verbatim behind the ABC; behavior byte-for-byte identical.PostgresKnowledgeGraph— SQLAlchemy 2.x Core, dialect-nativeON CONFLICTupserts, JSONB on Postgres / JSON elsewhere via aTypeDecorator.mempalace/knowledge_graph.pybecomes a backwards-compat shim re-exporting the factory; existing callers don't need changes.Selection priority:
url=kwarg →MEMPALACE_DATABASE_URLenv →db_path=kwarg →MEMPALACE_KG_PATHenv →~/.mempalace/knowledge_graph.sqlite3(default).HTTP ChromaDB backend (
mempalace.backends.chroma_http)HttpChromaBackend(BaseBackend)wrapschromadb.HttpClient. One cached client per process; multiple palaces share the same chromadb server via namespace-prefixed collection names derived fromPalaceRef.namespace(oridfallback).MEMPALACE_CHROMA_URL/_HOST/_PORT/_SSL/_AUTH_TOKEN/_AUTH_HEADER/_TENANT/_DATABASE.detect()returns True iff any HTTP-mode env var is set, soresolve_backend_for_palacepicks it automatically.backends/registry.pyand[project.entry-points].Backend-aware searcher / repair / CLI
A new
mempalace._runtimemodule centralizes the "are we local or HTTP?" question (using_local_chroma(),using_http_chroma()).searcher._bm25_only_via_apimirrors the sqlite BM25 fallback throughwhere_document={"$contains": tok}. Same result shape so_merge_bm25_union_candidatesandsearch_memorieswork uniformly. The new_bm25_onlydispatcher routes by backend.mcp_server._refresh_vector_disabled_flagis a no-op in HTTP mode (segment health is server-side; the client never sees_vector_disabledflip).mcp_server._get_clientreturns a cachedHttpClientin HTTP mode (no inode/mtime watching).repair.statusandrepair --mode max-seq-idearly-exit with a clear refusal in HTTP mode — they manipulate ChromaDB's internal sqlite/max_seq_idtable which physically lives on the remote server.repair.rebuild_indexin HTTP mode swaps thechroma.sqlite3cross-check (mempalace repairsilently truncates drawers to 10,000 — data loss on palaces > 10K #1208 guard) for acollection.count()cross-check, skips the file-system backup, and surfaces "snapshot the chromadb server before rebuild" as a warning. Extract / recreate / re-upsert all flow through the public chromadb API.cli.cmd_repairdelegates torepair.rebuild_indexin HTTP mode, skipping the CLI's own palace-dir copytree backup (which requires local FS).Config surface
MempalaceConfiggrowsdatabase_url,chroma_url,chroma_host/port/ssl/tenant/database, andbackend_modeproperties so a single config object answers both "where is my palace?" and "which backend will I use?" without callers cross-importing_runtime.Architectural note on "feature parity"
Two operator-only diagnostic commands physically cannot be implemented client-side over HTTP:
mempalace repair-statusindex_metadata.picklefrom diskmempalace repair --mode max-seq-idmax_seq_idtableBoth operate on storage that lives on the remote chromadb server in HTTP mode. There is no chromadb HTTP API for either. The CLI prints a clear refusal pointing at the chromadb server. Every end-user path (
mine,search, KG, all MCP tools,scan,prune,rebuild) has full parity.Tests
tests/test_kg_conformance.py— 13 behavioral tests parametrized over[SqliteKnowledgeGraph, PostgresKnowledgeGraph (sqlite-via-sqlalchemy), PostgresKnowledgeGraph (real Postgres via testcontainers)]. Real-Postgres tier is gated behind@pytest.mark.postgresand skipped when docker is unavailable.tests/test_chroma_http_backend.py— 28 unit tests (URL parsing, env resolution, namespace qualification, registry membership,detect(),close()idempotency) plus a@pytest.mark.chroma_httpintegration test against eitherCHROMADB_TEST_URLor achromadb/chroma:0.5.20testcontainer.KnowledgeGraph-becomes-factory change (test_kg_thread_safety.pynow asserts againstSqliteKnowledgeGraph.close); README badge regex test URL-decodes the version-segment+.pytest -q→ 1521 passed, 14 skipped, 106 deselected, no regressions.Packaging
3.3.4+stateless.1(PEP 440 local-version segment so Renovate keeps tracking upstream while this fork is identifiable).[postgres]and[remote]pull insqlalchemy>=2,<3,psycopg[binary]>=3.2,alembic>=1.13.chroma_http = "mempalace.backends.chroma_http:HttpChromaBackend"undermempalace.backends.How to use
Or invoked from a Kubernetes single-replica StatefulSet behind a ToolHive
MCPServerwith the same env vars. Reference container images:ghcr.io/shockstruck/mempalace-mcp(CPU + OpenVINO variants).Out of scope (explicit)
mempalace migrateis a follow-up).