Files
hermes-agent/gateway/hosted_room_replicas.py
T

406 lines
18 KiB
Python

"""Replica store and takeover primitives for hosted Group Chat rooms.
The authority gateway owns a room's ordered log in ``gateway/hosted_rooms.py``;
this module gives every OTHER participant gateway a durable local copy plus the
fenced primitives to continue the room when the authority host dies:
``ingest_page()`` (idempotent, gap- and epoch-regression-safe replay),
``promote_replica()`` (resume locally at ``epoch + 1`` with a lineage-proving
``authority.claimed`` event) and ``demote_room()`` (a returning stale authority
records ``authority.lost`` when shown proof of a newer epoch).
Storage primitives only: none decide *when* takeover is safe. The caller must
establish that the previous owner can no longer commit before promoting.
"""
from __future__ import annotations
import json
import sqlite3
import time
from contextlib import contextmanager
from pathlib import Path
from typing import Any, Iterator
from gateway.hosted_rooms import (
MAX_ACTOR_ID_CHARS,
MAX_EVENT_JSON_BYTES,
MAX_ROOM_ID_CHARS,
HostedRoomError,
RoomConflictError,
_canonical_json,
_connect,
_transaction,
_validate_identifier,
_validate_members,
_validate_room_name,
local_authority_gateway_id,
)
from gateway.hosted_rooms_common import positive_int, utf8_len
MAX_REPLICA_ROOMS = 256
MAX_REPLICA_EVENT_BYTES = 256 * 1024 * 1024
_SYSTEM_ACTOR = {"kind": "system", "id": "authority-control"}
_EVENT_COLUMNS = "(room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at)"
_INSERT_ROOM_EVENT = f"INSERT INTO hosted_room_events {_EVENT_COLUMNS} VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
_INSERT_REPLICA_EVENT = f"INSERT INTO hosted_room_replica_events {_EVENT_COLUMNS} VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
_SELECT_REPLICA = "SELECT * FROM hosted_room_replicas WHERE room_id=?"
class ReplicaError(HostedRoomError):
"""Base class for invalid or conflicting replica operations."""
class ReplicaGapError(ReplicaError):
"""A page does not start at the replica's next expected sequence."""
class ReplicaEpochRegressionError(ReplicaError):
"""A page or demotion carries an older authority epoch than stored."""
def _initialize_replica_schema(conn: sqlite3.Connection) -> None:
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_replicas (
room_id TEXT PRIMARY KEY, name TEXT NOT NULL, members_json TEXT NOT NULL,
authority_gateway_id TEXT NOT NULL,
authority_epoch INTEGER NOT NULL CHECK (authority_epoch >= 1),
last_seq INTEGER NOT NULL DEFAULT 0 CHECK (last_seq >= 0),
latest_seq INTEGER NOT NULL DEFAULT 0, event_bytes INTEGER NOT NULL DEFAULT 0,
created_at REAL NOT NULL, updated_at REAL NOT NULL
)"""
)
conn.execute(
"""CREATE TABLE IF NOT EXISTS hosted_room_replica_events (
room_id TEXT NOT NULL, seq INTEGER NOT NULL CHECK (seq >= 1), event_id TEXT NOT NULL,
kind TEXT NOT NULL, actor_json TEXT NOT NULL, authority_epoch INTEGER,
payload_json TEXT NOT NULL, created_at REAL NOT NULL,
PRIMARY KEY (room_id, seq)
)"""
)
@contextmanager
def _replica_transaction(db_path: Path | str) -> Iterator[sqlite3.Connection]:
"""Ensure the replica schema (own autocommit connection), then open an IMMEDIATE transaction.
The schema DDL is re-run inside the transaction too; that double init is the
established statement order and is intentionally preserved.
"""
conn = _connect(db_path)
try:
with conn:
_initialize_replica_schema(conn)
finally:
conn.close()
with _transaction(db_path, immediate=True) as conn:
_initialize_replica_schema(conn)
yield conn
def _room_id(value: Any) -> str:
return _validate_identifier(value, label="room_id", max_chars=MAX_ROOM_ID_CHARS)
def _positive_int(value: Any, message: str) -> int:
return positive_int(value, error=ReplicaError, message=message)
def _control_event_json(payload: dict[str, Any]) -> tuple[str, str]:
"""Canonical (actor_json, payload_json) for a system authority-control event."""
return (
_canonical_json(_SYSTEM_ACTOR, label="actor", max_bytes=4 * 1024),
_canonical_json(payload, label="payload", max_bytes=MAX_EVENT_JSON_BYTES),
)
def _event_bytes(event: dict[str, Any]) -> int:
return utf8_len(
str(event["event_id"]), str(event["kind"]),
json.dumps(event["actor"], ensure_ascii=False, separators=(",", ":")),
json.dumps(event["payload"], ensure_ascii=False, separators=(",", ":")),
)
def _validate_page(page: Any) -> tuple[list[dict[str, Any]], dict[str, Any]]:
if not isinstance(page, dict):
raise ReplicaError("page must be an object")
events = page.get("events")
authority = page.get("authority")
if not isinstance(events, list):
raise ReplicaError("page.events must be a list")
if not isinstance(authority, dict):
raise ReplicaError("page.authority is required for replication")
gateway_id = _validate_identifier(
authority.get("gateway_id"), label="page.authority.gateway_id", max_chars=MAX_ACTOR_ID_CHARS
)
epoch = _positive_int(authority.get("epoch"), "page.authority.epoch must be a positive integer")
previous_seq: int | None = None
for event in events:
if not isinstance(event, dict):
raise ReplicaError("page events must be objects")
seq = _positive_int(event.get("seq"), "event.seq must be a positive integer")
if previous_seq is not None and seq != previous_seq + 1:
raise ReplicaGapError("page events must be contiguous")
previous_seq = seq
for field in ("event_id", "kind"):
if not isinstance(event.get(field), str) or not event[field]:
raise ReplicaError(f"event.{field} must be a non-empty string")
if not isinstance(event.get("actor"), dict):
raise ReplicaError("event.actor must be an object")
if "payload" not in event:
raise ReplicaError("event.payload is required")
return events, {"gateway_id": gateway_id, "epoch": epoch}
def _replica_row_state(conn: sqlite3.Connection, room_id: str) -> tuple[sqlite3.Row | None, int, int, int]:
"""Return (row, stored_epoch, last_seq, stored_bytes); a new room is admitted only under the room cap."""
row = conn.execute(
"""SELECT authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes
FROM hosted_room_replicas WHERE room_id=?""",
(room_id,),
).fetchone()
if row is None:
count = conn.execute("SELECT COUNT(*) FROM hosted_room_replicas").fetchone()[0]
if int(count) >= MAX_REPLICA_ROOMS:
raise ReplicaError("replica room capacity exhausted")
return None, 0, 0, 0
return row, int(row["authority_epoch"]), int(row["last_seq"]), int(row["event_bytes"])
def _store_replica(
conn: sqlite3.Connection, *, is_new: bool, room_id: str, room_name: str, members_json: str,
authority: dict[str, Any], new_last: int, latest_seq: int, added_bytes: int, now: float,
) -> None:
"""INSERT the replica row for a new room, else UPDATE it (event_bytes accumulates)."""
values = (room_name, members_json, authority["gateway_id"], authority["epoch"], new_last, max(latest_seq, new_last))
if is_new:
conn.execute(
"""INSERT INTO hosted_room_replicas (room_id, name, members_json,
authority_gateway_id, authority_epoch, last_seq, latest_seq, event_bytes,
created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
(room_id, *values, added_bytes, now, now),
)
else:
conn.execute(
"""UPDATE hosted_room_replicas SET name=?, members_json=?, authority_gateway_id=?,
authority_epoch=?, last_seq=?, latest_seq=?, event_bytes=event_bytes+?,
updated_at=? WHERE room_id=?""",
(*values, added_bytes, now, room_id),
)
def ingest_page(
db_path: Path | str, *, room_id: Any, room_name: Any, members: Any, page: Any, now: float | None = None
) -> dict[str, Any]:
"""Persist one replay page idempotently; refuses seq gaps and epoch regressions.
``page`` is the verbatim ``groups.log`` (``read_events()``) result whose
``authority`` stamp proves lineage.
"""
room_id = _room_id(room_id)
room_name = _validate_room_name(room_name)
_, members_json = _validate_members(members)
events, authority = _validate_page(page)
now = time.time() if now is None else float(now)
with _replica_transaction(db_path) as conn:
row, stored_epoch, last_seq, stored_bytes = _replica_row_state(conn, room_id)
if authority["epoch"] < stored_epoch:
raise ReplicaEpochRegressionError("page authority epoch is older than the stored replica epoch")
new_events = [e for e in events if int(e["seq"]) > last_seq]
if new_events and int(new_events[0]["seq"]) != last_seq + 1:
raise ReplicaGapError("page skips sequences the replica has not stored")
added_bytes = 0
for event in new_events:
size = _event_bytes(event)
if stored_bytes + added_bytes + size > MAX_REPLICA_EVENT_BYTES:
raise ReplicaError("replica event storage exhausted")
actor_json = _canonical_json(event["actor"], label="actor", max_bytes=4 * 1024)
payload_json = _canonical_json(event["payload"], label="payload", max_bytes=MAX_EVENT_JSON_BYTES)
conn.execute(
_INSERT_REPLICA_EVENT,
(
room_id, int(event["seq"]), event["event_id"], event["kind"], actor_json,
event.get("authority_epoch"), payload_json, float(event.get("created_at") or now),
),
)
added_bytes += size
new_last = int(new_events[-1]["seq"]) if new_events else last_seq
latest_seq = page.get("latest_seq")
if isinstance(latest_seq, bool) or not isinstance(latest_seq, int):
latest_seq = new_last
_store_replica(
conn, is_new=row is None, room_id=room_id, room_name=room_name, members_json=members_json,
authority=authority, new_last=new_last, latest_seq=latest_seq, added_bytes=added_bytes, now=now,
)
return {
"room_id": room_id,
"stored_seq": new_last,
"ingested": len(new_events),
"authority": authority,
"caught_up": new_last >= max(latest_seq, new_last),
}
def replica_state(db_path: Path | str, *, room_id: Any) -> dict[str, Any]:
"""Return the stored replica's coverage and authority lineage."""
room_id = _room_id(room_id)
with _replica_transaction(db_path) as conn:
row = conn.execute(_SELECT_REPLICA, (room_id,)).fetchone()
if row is None:
raise ReplicaError("replica not found")
return {
"room_id": row["room_id"],
"name": row["name"],
"members": json.loads(row["members_json"]),
"authority": {"gateway_id": row["authority_gateway_id"], "epoch": int(row["authority_epoch"])},
"last_seq": int(row["last_seq"]),
"latest_seq": int(row["latest_seq"]),
"event_bytes": int(row["event_bytes"]),
"created_at": float(row["created_at"]),
"updated_at": float(row["updated_at"]),
}
def promote_replica(
db_path: Path | str, *, room_id: Any, reason: Any = "authority-unreachable", now: float | None = None
) -> dict[str, Any]:
"""Continue a replicated room on THIS gateway at ``epoch + 1``.
Copies the replica log into the authoritative store and appends a
lineage-proving ``authority.claimed`` event; wherever the claim replicates,
the old epoch is stale and every fenced primitive rejects it. The caller
decides takeover is safe; this only makes it atomic and provable.
"""
room_id = _room_id(room_id)
if not isinstance(reason, str) or not reason or len(reason) > 200:
raise ReplicaError("reason must be a non-empty string of at most 200 chars")
now = time.time() if now is None else float(now)
local_gateway = local_authority_gateway_id()
with _replica_transaction(db_path) as conn:
replica = conn.execute(_SELECT_REPLICA, (room_id,)).fetchone()
if replica is None:
raise ReplicaError("replica not found")
if replica["authority_gateway_id"] == local_gateway:
raise ReplicaError("this gateway already holds the room authority")
if conn.execute("SELECT 1 FROM hosted_rooms WHERE room_id=?", (room_id,)).fetchone():
raise RoomConflictError("room_id already exists in the local authoritative store")
if conn.execute("SELECT 1 FROM hosted_room_retired_ids WHERE room_id=?", (room_id,)).fetchone():
raise RoomConflictError("room_id belongs to a disbanded room")
previous_gateway = str(replica["authority_gateway_id"])
previous_epoch = int(replica["authority_epoch"])
target_epoch = previous_epoch + 1
claim_seq = int(replica["last_seq"]) + 1
claim_event_id = f"system:authority-claimed:{target_epoch}"
claim_actor_json, claim_payload_json = _control_event_json({
"previous_gateway_id": previous_gateway,
"authority_gateway_id": local_gateway,
"authority_epoch": target_epoch,
"promoted_from_replica": True,
"reason": reason,
})
claim_bytes = utf8_len(claim_event_id, "authority.claimed", claim_actor_json, claim_payload_json)
conn.execute(
"""INSERT INTO hosted_rooms
(room_id, name, members_json, authority_gateway_id, authority_epoch, next_seq, event_bytes,
revision, created_at, updated_at, disbanded_at)
VALUES (?, ?, ?, ?, ?, ?, ?, 1, ?, ?, NULL)""",
(
room_id, replica["name"], replica["members_json"], local_gateway, target_epoch,
claim_seq + 1, int(replica["event_bytes"]) + claim_bytes, now, now,
),
)
conn.execute(
f"""INSERT INTO hosted_room_events {_EVENT_COLUMNS}
SELECT room_id, seq, event_id, kind, actor_json, authority_epoch, payload_json, created_at
FROM hosted_room_replica_events WHERE room_id=?""",
(room_id,),
)
conn.execute(
_INSERT_ROOM_EVENT,
(
room_id, claim_seq, claim_event_id, "authority.claimed",
claim_actor_json, target_epoch, claim_payload_json, now,
),
)
conn.execute("DELETE FROM hosted_room_replica_events WHERE room_id=?", (room_id,))
conn.execute("DELETE FROM hosted_room_replicas WHERE room_id=?", (room_id,))
return {
"room_id": room_id,
"authority_gateway_id": local_gateway,
"authority_epoch": target_epoch,
"previous_gateway_id": previous_gateway,
"previous_epoch": previous_epoch,
"claim_seq": claim_seq,
"latest_seq": claim_seq,
}
def demote_room(
db_path: Path | str, *, room_id: Any, observed_gateway_id: Any, observed_epoch: Any, now: float | None = None
) -> dict[str, Any]:
"""Fence THIS gateway's stale room authority against a proven newer epoch.
Called when a returning gateway observes (replicated ``authority.claimed``
or a transport rejection) that another gateway owns the room at a higher
epoch. Appends ``authority.lost`` and adopts the observed lineage so no
local send can commit at the stale epoch. Idempotent per lineage.
"""
room_id = _room_id(room_id)
observed_gateway_id = _validate_identifier(
observed_gateway_id, label="observed_gateway_id", max_chars=MAX_ACTOR_ID_CHARS
)
observed_epoch = _positive_int(observed_epoch, "observed_epoch must be a positive integer")
now = time.time() if now is None else float(now)
local_gateway = local_authority_gateway_id()
with _transaction(db_path, immediate=True) as conn:
row = conn.execute(
"""SELECT authority_gateway_id, authority_epoch, next_seq
FROM hosted_rooms WHERE room_id=? AND disbanded_at IS NULL""",
(room_id,),
).fetchone()
if row is None:
raise ReplicaError("room not found in the local authoritative store")
current_gateway = str(row["authority_gateway_id"])
current_epoch = int(row["authority_epoch"])
if current_gateway == observed_gateway_id and current_epoch == observed_epoch:
return {
"room_id": room_id,
"authority_gateway_id": current_gateway,
"authority_epoch": current_epoch,
"idempotent": True,
}
if observed_epoch <= current_epoch:
raise ReplicaEpochRegressionError("observed epoch does not supersede the stored authority")
if current_gateway != local_gateway:
raise ReplicaError("room is not locally authoritative; nothing to demote")
lost_actor_json, lost_payload_json = _control_event_json({
"previous_gateway_id": current_gateway,
"authority_gateway_id": observed_gateway_id,
"authority_epoch": observed_epoch,
})
conn.execute(
_INSERT_ROOM_EVENT,
(
room_id, int(row["next_seq"]), f"system:authority-lost:{observed_epoch}",
"authority.lost", lost_actor_json, observed_epoch, lost_payload_json, now,
),
)
conn.execute(
"""UPDATE hosted_rooms
SET authority_gateway_id=?, authority_epoch=?, next_seq=next_seq+1, revision=revision+1, updated_at=?
WHERE room_id=?""",
(observed_gateway_id, observed_epoch, now, room_id),
)
return {
"room_id": room_id,
"authority_gateway_id": observed_gateway_id,
"authority_epoch": observed_epoch,
"idempotent": False,
}