orchestrata_ultimate.semantic_graph.ha_controller

Reference › orchestrata_ultimate › semantic_graph › ha_controller

A high-availability controller for Memgraph Community replication, the engine of the shared semantic graph (memgraph), which has no built-in HA. The controller monitors a set of data instances, enforces a single MAIN role, fails over automatically and reintegrates recovered nodes as replicas. It also serves an HTTP API so clients can discover the current MAIN URI without hardcoding it. It is deliberately a single-controller design that persists its state to SQLite so it survives restarts; its docstring notes that a three-node Raft controller cluster could follow. The command-line wrapper is ha_controller_cli, and main_discovery mentions it as the legacy discovery route.

Three dataclasses model the cluster: the frozen NodeConfig (name, bolt URI, replication host), NodeState (runtime health of one instance) and ClusterStatus (current MAIN, epoch and node states, returned by the status endpoint). MemgraphHAController takes the node list, a state database path, a poll interval (2 seconds by default), a replication mode (ASYNC by default) and a stall threshold. run loads state (_load_state restores the current MAIN from SQLite or bootstraps from the first node) and starts the monitoring loop. Each _poll_cycle updates node health (_update_node_health, _check_node), reconciles roles (_reconcile_roles), performs failover when MAIN is unhealthy (_perform_failover) and idempotently registers healthy non-MAIN nodes as replicas (_ensure_replicas_registered), reading SHOW REPLICAS data and tracking per-cycle replication progress to detect a frozen but ready replica. The HTTP surface is a FastAPI app exposed through the app property.

At a glance

Source src/orchestrata_ultimate/semantic_graph/ha_controller.py
Lines of code 588
Classes 4
Functions 1
Imports (internal) orchestrata_ultimate.core.graph_factory
Imported by orchestrata_ultimate.semantic_graph.ha_controller_cli, orchestrata_ultimate.semantic_graph.main_discovery
Tests that import it tests/semantic_graph/test_ha_replica_health.py, tests/semantic_graph/test_memgraph_ha.py

Class diagram

Class diagram orchestrata_ultimate.semantic_graph.ha_controller

Dependency diagram

Dependency diagram orchestrata_ultimate.semantic_graph.ha_controller

API

Constants

Name Type Value
_HEALTHY_REPLICA_STATUSES frozenset[str] frozenset({'ready', 'replicating', 'recovery'})

Classes

NodeConfig (dataclass)

Decorators: dataclass(frozen=True) · Line 37

Configuration for one Memgraph data instance.

Attribute Type Default
name str
bolt_uri str
replication_host str
replication_port int 10000
username str ''
password str ''

NodeState (dataclass)

Decorators: dataclass · Line 49

Runtime state of one Memgraph data instance.

Attribute Type Default
config NodeConfig
role str 'unknown'
healthy bool False
last_seen float 0.0
error str | None None

ClusterStatus (dataclass)

Decorators: dataclass · Line 60

Snapshot returned by the HTTP status endpoint.

Attribute Type Default
current_main str | None
epoch int
nodes list[NodeState]

MemgraphHAController

Line 99

Monitor and manage a Memgraph replication cluster.

Method Signature Summary
__init__ __init__(nodes: list[NodeConfig], db_path: str = '/var/lib/memgraph-ha/state.db', poll_interval: float = 2.0, replication_mode: str = 'ASYNC', stall_cycles_threshold: int = 10) -> None
app property app() -> FastAPI
_setup_routes _setup_routes() -> None
run async run() -> None Initialize state and start the monitoring loop.
_init_db _init_db() -> None
_load_state async _load_state() -> None Restore current_main from SQLite, or bootstrap from first node.
_save_state async _save_state() -> None
_poll_cycle async _poll_cycle() -> None
_update_node_health async _update_node_health() -> None
_check_node async _check_node(state: NodeState) -> None
_reconcile_roles async _reconcile_roles() -> None
_perform_failover async _perform_failover() -> None
_ensure_replicas_registered async _ensure_replicas_registered(main_state: NodeState) -> None Idempotently register every healthy non-MAIN node as a replica.
_note_replica_progress _note_replica_progress(name: str, data_info: dict[str, Any] | None) -> bool Track per-cycle replication progress; True when frozen-but-ready.
_replica_is_healthy staticmethod _replica_is_healthy(data_info: dict[str, Any] | None) -> bool True iff a SHOW REPLICAS data_info reports a healthy/syncing status.
_show_replicas async _show_replicas(main_config: NodeConfig) -> dict[str, dict[str, Any]] Return {replica_name: data_info} for replicas registered on the MAIN.
_demote_stray_mains async _demote_stray_mains(main_state: NodeState) -> None Demote any healthy node that thinks it is MAIN but is not the current MAIN.
_query_role async _query_role(config: NodeConfig) -> str
_set_role async _set_role(config: NodeConfig, role: str) -> None
_register_replica async _register_replica(main_config: NodeConfig, replica_config: NodeConfig, *, drop_first: bool = True) -> bool (Re)register replica_config on the MAIN.
_current_main _current_main() -> NodeState | None
_status_dict _status_dict() -> dict[str, Any]

Functions

Function Signature Summary
probe_coordinator_for_main probe_coordinator_for_main(bolt_uri: str) -> str | None Ask one Memgraph coordinator (SHOW INSTANCES) for the current MAIN data-instance's bolt URI, or None (unreachable, not a coordinator, or no MAIN reported — all best-effort).

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/src/orchestrata_ultimate/semantic_graph/ha_controller.py

"""High-availability controller for Memgraph Community replication.

The controller monitors a set of Memgraph data instances, enforces a single
MAIN role, performs automatic failover, and reintegrates recovered nodes as
REPLICAs (autofailback). It exposes an HTTP API so clients can discover the
current MAIN URI without hardcoding it.

This is intentionally a single-controller design. It is the source of truth for
the cluster and persists its state to SQLite so it survives restarts. A future
iteration could turn this into a 3-node Raft controller cluster.
"""

from __future__ import annotations

import logging
import sqlite3
import time
from dataclasses import dataclass
from typing import Any

import anyio
import anyio.to_thread
from fastapi import FastAPI

logger = logging.getLogger(__name__)

# SHOW REPLICAS data_info statuses that mean "healthy, leave it alone": Memgraph
# reports "ready" (in sync) plus "replicating"/"recovery" (actively catching up).
# Anything else (e.g. "invalid") — or no entry at all — means the replica must be
# (re)registered.
_HEALTHY_REPLICA_STATUSES: frozenset[str] = frozenset(
    {"ready", "replicating", "recovery"}
)


@dataclass(frozen=True)
class NodeConfig:
    """Configuration for one Memgraph data instance."""

    name: str
    bolt_uri: str
    replication_host: str
    replication_port: int = 10000
    username: str = ""
    password: str = ""


@dataclass
class NodeState:
    """Runtime state of one Memgraph data instance."""

    config: NodeConfig
    role: str = "unknown"
    healthy: bool = False
    last_seen: float = 0.0
    error: str | None = None


@dataclass
class ClusterStatus:
    """Snapshot returned by the HTTP status endpoint."""

    current_main: str | None
    epoch: int
    nodes: list[NodeState]


def probe_coordinator_for_main(bolt_uri: str) -> str | None:
    """Ask one Memgraph coordinator (``SHOW INSTANCES``) for the current MAIN
    data-instance's bolt URI, or ``None`` (unreachable, not a coordinator, or no
    MAIN reported — all best-effort).

    ``SHOW INSTANCES`` is a Raft admin query only a *coordinator* answers over a
    direct (non-routed) ``bolt://`` connection. This is the single raw-driver
    coordinator probe for MAIN discovery; it lives here — the allowlisted
    Memgraph HA seam — so clients like :mod:`semantic_graph.main_discovery` need
    no raw graph access of their own (D13 graph-seam rule).
    """
    from orchestrata_ultimate.core.graph_factory import NO_AUTH, create_graph

    try:
        driver = create_graph(uri=bolt_uri, auth=NO_AUTH, connection_timeout=5)
    except Exception:  # noqa: BLE001 - probing is inherently best-effort
        return None
    try:
        with driver.session() as session:
            for record in session.run("SHOW INSTANCES"):
                if record.get("role") == "main":
                    bolt_server = record.get("bolt_server")
                    if bolt_server:
                        return f"bolt://{bolt_server}"
    except Exception:  # noqa: BLE001 - probing is inherently best-effort
        return None
    finally:
        driver.close()
    return None


class MemgraphHAController:
    """Monitor and manage a Memgraph replication cluster."""

    def __init__(
        self,
        nodes: list[NodeConfig],
        db_path: str = "/var/lib/memgraph-ha/state.db",
        poll_interval: float = 2.0,
        replication_mode: str = "ASYNC",
        stall_cycles_threshold: int = 10,
    ) -> None:
        self._nodes = {node.name: NodeState(config=node) for node in nodes}
        self._db_path = db_path
        self._poll_interval = poll_interval
        self._replication_mode = replication_mode.upper()
        self._stall_cycles_threshold = stall_cycles_threshold
        # Per-replica (last_seen_ts, consecutive_frozen_cycles) — detects
        # replicas that report 'ready' while their replication timestamp is
        # frozen with data outstanding (post-reboot incident 2026-07-08).
        # See _note_replica_progress.
        self._replica_progress: dict[str, tuple[int | None, int]] = {}
        self._persisted_main: str = ""
        self._epoch: int = 0
        self._app = FastAPI(title="Memgraph HA Controller")
        self._setup_routes()

    @property
    def app(self) -> FastAPI:
        return self._app

    def _setup_routes(self) -> None:
        @self._app.get("/status")
        async def status() -> dict[str, Any]:
            return self._status_dict()

        @self._app.get("/main")
        async def main() -> dict[str, Any]:
            current = self._current_main()
            if current is None:
                return {"main": None, "uri": None}
            return {"main": current.config.name, "uri": current.config.bolt_uri}

        @self._app.post("/failover")
        async def failover() -> dict[str, Any]:
            await self._perform_failover()
            return self._status_dict()

    async def run(self) -> None:
        """Initialize state and start the monitoring loop."""
        await anyio.to_thread.run_sync(self._init_db)
        await self._load_state()
        while True:
            try:
                await self._poll_cycle()
            except Exception as exc:
                logger.exception("Polling cycle failed: %s", exc)
            await anyio.sleep(self._poll_interval)

    # ------------------------------------------------------------------
    # State persistence
    # ------------------------------------------------------------------

    def _init_db(self) -> None:
        parent = self._db_path.rsplit("/", 1)[0]
        if parent:
            import os

            os.makedirs(parent, exist_ok=True)
        with sqlite3.connect(self._db_path) as conn:
            conn.execute("""
                CREATE TABLE IF NOT EXISTS cluster_state (
                    id INTEGER PRIMARY KEY CHECK (id = 1),
                    current_main TEXT NOT NULL,
                    epoch INTEGER NOT NULL,
                    updated_at REAL NOT NULL
                )
                """)
            conn.commit()

    async def _load_state(self) -> None:
        """Restore current_main from SQLite, or bootstrap from first node."""

        def _read() -> tuple[str, int] | None:
            with sqlite3.connect(self._db_path) as conn:
                row = conn.execute(
                    "SELECT current_main, epoch FROM cluster_state WHERE id = 1"
                ).fetchone()
                if row is None:
                    return None
                return row[0], row[1]

        state = await anyio.to_thread.run_sync(_read)
        if state is not None:
            current_main, epoch = state
            logger.info("Restored cluster state: main=%s epoch=%d", current_main, epoch)
            self._persisted_main = current_main
            self._epoch = epoch
        else:
            first = next(iter(self._nodes.values())).config.name
            logger.info("Bootstrapping cluster with main=%s", first)
            self._persisted_main = first
            self._epoch = 1
            await self._save_state()

    async def _save_state(self) -> None:
        def _write() -> None:
            with sqlite3.connect(self._db_path) as conn:
                conn.execute(
                    """
                    INSERT INTO cluster_state (id, current_main, epoch, updated_at)
                    VALUES (1, ?, ?, ?)
                    ON CONFLICT(id) DO UPDATE SET
                        current_main = excluded.current_main,
                        epoch = excluded.epoch,
                        updated_at = excluded.updated_at
                    """,
                    (self._persisted_main, self._epoch, time.time()),
                )
                conn.commit()

        await anyio.to_thread.run_sync(_write)

    # ------------------------------------------------------------------
    # Polling and reconciliation
    # ------------------------------------------------------------------

    async def _poll_cycle(self) -> None:
        await self._update_node_health()
        await self._reconcile_roles()

    async def _update_node_health(self) -> None:
        async with anyio.create_task_group() as tg:
            for state in self._nodes.values():
                tg.start_soon(self._check_node, state)

    async def _check_node(self, state: NodeState) -> None:
        try:
            role = await self._query_role(state.config)
            state.role = role
            state.healthy = True
            state.last_seen = time.time()
            state.error = None
        except Exception as exc:
            state.healthy = False
            state.error = str(exc)
            logger.debug("Node %s health check failed: %s", state.config.name, exc)

    async def _reconcile_roles(self) -> None:
        healthy_nodes = [s for s in self._nodes.values() if s.healthy]
        if len(healthy_nodes) < 2:
            logger.critical(
                "Only %d healthy node(s) (%s); role changes skipped (split-brain guard). "
                "The cluster cannot self-heal until at least 2 nodes are reachable.",
                len(healthy_nodes),
                ", ".join(s.config.name for s in healthy_nodes),
            )
            return

        current_main_state = self._nodes.get(self._persisted_main)

        # If the persisted MAIN is healthy and actually MAIN, ensure replicas are registered.
        if (
            current_main_state is not None
            and current_main_state.healthy
            and current_main_state.role == "main"
        ):
            await self._ensure_replicas_registered(current_main_state)
            await self._demote_stray_mains(current_main_state)
            return

        # MAIN is down or demoted. Perform failover.
        await self._perform_failover()

    async def _perform_failover(self) -> None:
        healthy = [s for s in self._nodes.values() if s.healthy]
        if len(healthy) < 2:
            logger.critical(
                "Cannot failover: only %d healthy node(s) (%s). "
                "Restart at least one more node.",
                len(healthy),
                ", ".join(s.config.name for s in healthy),
            )
            return

        # Prefer a node that already reports as replica.
        candidates = [s for s in healthy if s.role == "replica"]
        if not candidates:
            candidates = [s for s in healthy if s.role in ("main", "unknown")]
        if not candidates:
            logger.warning("No failover candidate available")
            return

        new_main = candidates[0]
        logger.warning(
            "Failing over from %s to %s", self._persisted_main, new_main.config.name
        )

        # Promote the candidate to MAIN.
        await self._set_role(new_main.config, "main")
        new_main.role = "main"

        # Update persisted state.
        self._persisted_main = new_main.config.name
        self._epoch += 1
        await self._save_state()

        # Register remaining healthy nodes as replicas of the new MAIN.
        await self._ensure_replicas_registered(new_main)
        await self._demote_stray_mains(new_main)

    async def _ensure_replicas_registered(self, main_state: NodeState) -> None:
        """Idempotently register every healthy non-MAIN node as a replica.

        Non-destructive: a replica already registered AND in a healthy status is
        left ALONE — never DROP+re-REGISTERed. Blindly tearing down a working
        registration every reconcile cycle (the previous behaviour) is exactly how
        the cluster once ended up with zero live replicas — one cycle DROPped the
        working replicas and the following REGISTER failed, stranding them. Each
        replica is handled in isolation so one failure cannot abort the others or
        bubble up and kill the reconcile loop (``_register_replica`` never raises).
        """
        try:
            live = await self._show_replicas(main_state.config)
        except Exception as exc:
            logger.warning(
                "SHOW REPLICAS on %s failed; will (re)register defensively: %s",
                main_state.config.name,
                exc,
            )
            live = {}
        for state in self._nodes.values():
            if state.config.name == main_state.config.name or not state.healthy:
                continue
            if state.role != "replica":
                try:
                    await self._set_role(state.config, "replica")
                    state.role = "replica"
                except Exception as exc:
                    logger.warning(
                        "Setting REPLICA role on %s failed: %s", state.config.name, exc
                    )
                    continue
            data_info = live.get(state.config.name)
            if self._replica_is_healthy(data_info):
                if not self._note_replica_progress(state.config.name, data_info):
                    continue  # registered, healthy, progressing — do not disturb
                logger.warning(
                    "Replica %s reports 'ready' but its replication timestamp "
                    "has been frozen with data outstanding for %d consecutive "
                    "cycles — forcing re-registration",
                    state.config.name,
                    self._stall_cycles_threshold,
                )
            else:
                self._replica_progress.pop(state.config.name, None)
            await self._register_replica(
                main_state.config,
                state.config,
                drop_first=state.config.name in live,
            )

    def _note_replica_progress(
        self, name: str, data_info: dict[str, Any] | None
    ) -> bool:
        """Track per-cycle replication progress; True when frozen-but-ready.

        The post-reboot incident (2026-07-08) left both replicas reporting
        ``status='ready'`` while their replication timestamp sat frozen 131
        transactions behind MAIN — a state ``_replica_is_healthy`` cannot
        see, so reconcile never repaired it. A replica counts as *frozen*
        when, for ``stall_cycles_threshold`` consecutive poll cycles, it is
        'ready' AND has data outstanding (``behind != 0``) AND its ``ts``
        never advances. Genuine catch-up ('recovery' status) is exempt —
        only the pathological ready-but-stuck state triggers.

        Returns True at the threshold and resets the counter so the forced
        re-registration gets a full window before firing again.
        """
        info: dict[str, Any] | None = None
        if data_info:
            info = next((v for v in data_info.values() if isinstance(v, dict)), None)
        if info is None or info.get("status") != "ready":
            self._replica_progress.pop(name, None)
            return False
        behind = info.get("behind")
        ts = info.get("ts")
        if not isinstance(behind, int) or behind == 0 or not isinstance(ts, int):
            self._replica_progress.pop(name, None)
            return False
        last_ts, cycles = self._replica_progress.get(name, (None, 0))
        cycles = cycles + 1 if last_ts == ts else 0
        self._replica_progress[name] = (ts, cycles)
        if cycles >= self._stall_cycles_threshold:
            del self._replica_progress[name]
            return True
        return False

    @staticmethod
    def _replica_is_healthy(data_info: dict[str, Any] | None) -> bool:
        """True iff a SHOW REPLICAS ``data_info`` reports a healthy/syncing status.

        Pure decision function (no I/O). ``data_info`` is the column Memgraph
        returns per replica — ``{"memgraph": {"status": "ready"|"recovery"|...}}``
        — or ``None`` when the replica is not registered at all. A missing entry
        or any non-healthy status means it must be (re)registered.
        """
        if not data_info:
            return False
        for info in data_info.values():
            if not isinstance(info, dict):
                return False
            if info.get("status") not in _HEALTHY_REPLICA_STATUSES:
                return False
        return True

    async def _show_replicas(
        self, main_config: NodeConfig
    ) -> dict[str, dict[str, Any]]:
        """Return ``{replica_name: data_info}`` for replicas registered on the MAIN."""

        def _run() -> dict[str, dict[str, Any]]:
            from orchestrata_ultimate.core.graph_factory import NO_AUTH, create_graph

            driver = create_graph(uri=main_config.bolt_uri, auth=NO_AUTH)
            try:
                with driver.session() as session:
                    out: dict[str, dict[str, Any]] = {}
                    for record in session.run("SHOW REPLICAS"):
                        name = record.get("name")
                        if name:
                            out[str(name)] = record.get("data_info") or {}
                    return out
            finally:
                driver.close()

        return await anyio.to_thread.run_sync(_run)

    async def _demote_stray_mains(self, main_state: NodeState) -> None:
        """Demote any healthy node that thinks it is MAIN but is not the current MAIN."""
        for state in self._nodes.values():
            if state.config.name == main_state.config.name:
                continue
            if state.healthy and state.role == "main":
                logger.warning(
                    "Demoting stray main %s (current main is %s)",
                    state.config.name,
                    main_state.config.name,
                )
                await self._set_role(state.config, "replica")
                state.role = (
                    "replica"  # pragma: no cover - requires a real stray-main demotion
                )
                await self._register_replica(main_state.config, state.config)

    # ------------------------------------------------------------------
    # Bolt helpers
    # ------------------------------------------------------------------

    async def _query_role(self, config: NodeConfig) -> str:
        def _run() -> str:
            from orchestrata_ultimate.core.graph_factory import NO_AUTH, create_graph

            driver = create_graph(uri=config.bolt_uri, auth=NO_AUTH)
            try:
                with driver.session() as session:
                    result = session.run("SHOW REPLICATION ROLE")
                    record = result.single()
                    if record is None:
                        return "unknown"  # pragma: no cover - Memgraph always returns a role
                    return str(record[0]).lower()
            finally:
                driver.close()

        return await anyio.to_thread.run_sync(_run)

    async def _set_role(self, config: NodeConfig, role: str) -> None:
        def _run() -> None:
            from orchestrata_ultimate.core.graph_factory import NO_AUTH, create_graph

            driver = create_graph(uri=config.bolt_uri, auth=NO_AUTH)
            try:
                with driver.session() as session:
                    if role not in ("main", "replica"):
                        raise ValueError(f"Unknown role: {role}")
                    # Memgraph raises on a no-op SET REPLICATION ROLE (e.g.
                    # "TO MAIN" on a node that already IS main), so skip when
                    # the node is already in the target role. Before the
                    # consume() below, that failure was silently discarded and
                    # the tests only passed because of it — a REPLICA set-role
                    # failure would likewise have gone unnoticed in a real
                    # failover, with the controller believing it succeeded.
                    record = session.run("SHOW REPLICATION ROLE").single()
                    current = str(record["replication role"]).lower() if record else ""
                    if current == role:
                        return
                    if role == "main":
                        session.run("SET REPLICATION ROLE TO MAIN").consume()
                    else:
                        session.run(
                            f"SET REPLICATION ROLE TO REPLICA WITH PORT {config.replication_port}"
                        ).consume()
            finally:
                driver.close()

        await anyio.to_thread.run_sync(_run)

    async def _register_replica(
        self,
        main_config: NodeConfig,
        replica_config: NodeConfig,
        *,
        drop_first: bool = True,
    ) -> bool:
        """(Re)register ``replica_config`` on the MAIN. Never raises; returns success.

        ``drop_first`` DROPs any existing registration before re-adding — needed
        when an already-registered replica is in a bad state. For a fresh
        registration it is skipped, avoiding a spurious "failed to unregister"
        error against a replica that was never registered. A failed REGISTER is
        logged and swallowed so it can be retried next reconcile cycle instead of
        killing the loop (the un-wrapped REGISTER here previously did just that).
        """

        def _run() -> None:
            from orchestrata_ultimate.core.graph_factory import NO_AUTH, create_graph

            driver = create_graph(uri=main_config.bolt_uri, auth=NO_AUTH)
            try:
                with driver.session() as session:
                    if drop_first:
                        # gate-ok: absence is the expected outcome of this best-effort pre-clean; a genuinely wrong state still fails loudly at the REGISTER REPLICA below
                        try:
                            # consume() so a server-side failure raises here —
                            # an unconsumed result is discarded on session
                            # close and the except below never fires.
                            session.run(
                                f"DROP REPLICA `{replica_config.name}`"
                            ).consume()
                        except Exception as exc:
                            logger.debug(
                                "DROP REPLICA %s failed (may not exist): %s",
                                replica_config.name,
                                exc,
                            )
                    session.run(
                        f"REGISTER REPLICA `{replica_config.name}` "
                        f"{self._replication_mode} TO "
                        f"'{replica_config.replication_host}:{replica_config.replication_port}'"
                    ).consume()
            finally:
                driver.close()

        try:
            await anyio.to_thread.run_sync(_run)
        except Exception as exc:
            logger.warning(
                "REGISTER REPLICA %s on %s failed (will retry next cycle): %s",
                replica_config.name,
                main_config.name,
                exc,
            )
            return False
        logger.info(
            "Registered replica %s on %s", replica_config.name, main_config.name
        )
        return True

    # ------------------------------------------------------------------
    # Status helpers
    # ------------------------------------------------------------------

    def _current_main(self) -> NodeState | None:
        return self._nodes.get(self._persisted_main)

    def _status_dict(self) -> dict[str, Any]:
        return {
            "current_main": self._persisted_main,
            "epoch": self._epoch,
            "nodes": [
                {
                    "name": s.config.name,
                    "bolt_uri": s.config.bolt_uri,
                    "role": s.role,
                    "healthy": s.healthy,
                    "last_seen": s.last_seen,
                    "error": s.error,
                }
                for s in self._nodes.values()
            ],
        }