Files
paskia/paskia/satellite.py
T
LeoVasanko 5ecb10166d Add remote domains: RAM replica + RAM-only sync channel
DomainConfig.remote {url, token, cache_ttl, refresh_interval} marks a
domain as backed by a remote paskia instance (auth host required). The
remote publishes committed changes via struct store()/delete() hooks and
explicit emits in field-mutating operations into syncfeed, an in-RAM
sequenced ring buffer served over a token-gated WebSocket
(/auth/api/sync/ws, tokens from PASKIA_SYNC_TOKENS env). The satellite
keeps a plain DB replica per remote URL, applies snapshots/events,
enforces expiry locally, and writes session refreshes back over the same
channel. /validate refreshes locally with write-behind; /logout,
/set-session, /token-info and /auth/oidc/* are proxied to the remote
with the original Host header; logout also evicts from the replica.
Replicas go fail-closed (503) after cache_ttl of silence.
2026-09-20 23:21:08 +00:00

249 lines
8.7 KiB
Python

"""Satellite side of remote domains: RAM-only read replicas.
For each domain configured with ``DomainConfig.remote`` a replica of the
remote's tables (another plain DB instance, never persisted) is attached to
the runtime Domain as its store, fed by a sync WebSocket to the remote and
refreshed by periodic full snapshots. Session refreshes from /validate are
written back over the same channel.
While the sync channel has been silent for longer than the domain's
cache_ttl the replica is considered unavailable (fail-closed; set a large
cache_ttl for fail-open behavior bounded by session expiry).
"""
import asyncio
import logging
import time
from datetime import UTC, datetime
from uuid import UUID
import msgspec
import websockets
from paskia import domains
from paskia.authsession import EXPIRES
from paskia.db.structs import (
DB,
Credential,
Org,
Permission,
RemoteConfig,
Role,
Session,
User,
)
from paskia.util.crypto import hash_secret
_logger = logging.getLogger(__name__)
_TABLES = {
"permissions": (Permission, True),
"orgs": (Org, True),
"roles": (Role, True),
"users": (User, True),
"credentials": (Credential, True),
"sessions": (Session, False),
}
_RECONNECT_DELAY = 5
_SWEEP_INTERVAL = 60
def _apply(replica: DB, table: str, key: str, op: str, fields: dict | None) -> None:
cls, uuid_key = _TABLES[table]
store = getattr(replica, table)
store_key = UUID(key) if uuid_key else key
if op == "delete":
store.pop(store_key, None)
return
obj = msgspec.convert(fields, cls)
if uuid_key:
obj.uuid = store_key
else:
obj.key = key
store[store_key] = obj
class RemoteReplica:
"""One remote instance's replica, its sync client and write-behind queue."""
def __init__(self, remote: RemoteConfig):
self.remote = remote
self.db = DB()
self.generation: str | None = None
self.seq = 0
self.last_contact = 0.0 # monotonic time of last snapshot/event
self._pending_refresh: dict[str, dict] = {}
self._refresh_signal = asyncio.Event()
self._task: asyncio.Task | None = None
self._sweeper: asyncio.Task | None = None
self._stopped = True
def available(self) -> bool:
return (
self.last_contact > 0
and time.monotonic() - self.last_contact <= self.remote.cache_ttl
)
def refresh_session(self, key: str, validated, ip: str, user_agent: str) -> None:
"""Apply a /validate refresh locally and queue it for the remote."""
session = self.db.sessions.get(key)
if session is not None:
session.validated = validated
session.ip = ip
session.user_agent = user_agent
self._pending_refresh[key] = {
"type": "session_refresh",
"key": key,
"validated": msgspec.to_builtins(validated),
"ip": ip,
"user_agent": user_agent,
}
self._refresh_signal.set()
def evict_session(self, secret: str) -> None:
self.db.sessions.pop(hash_secret("cookie", secret), None)
async def start(self) -> None:
self._stopped = False
self._task = asyncio.create_task(self._run())
self._sweeper = asyncio.create_task(self._sweep())
async def stop(self) -> None:
self._stopped = True
for task in (self._task, self._sweeper):
if task:
task.cancel()
with asyncio.suppress(asyncio.CancelledError):
await task
async def _sweep(self) -> None:
while True:
await asyncio.sleep(_SWEEP_INTERVAL)
limit = datetime.now(UTC) - EXPIRES
for key in [k for k, s in self.db.sessions.items() if s.validated < limit]:
del self.db.sessions[key]
async def _run(self) -> None:
while not self._stopped:
try:
await self._connect()
except asyncio.CancelledError:
raise
except Exception as e:
_logger.info("Sync to %s failed: %s", self.remote.url, e)
if not self._stopped:
await asyncio.sleep(_RECONNECT_DELAY)
async def _connect(self) -> None:
ws_url = self.remote.url.replace("http", "ws", 1) + "/auth/api/sync/ws"
# Periodic full snapshots reconcile any drift; resume is cheaper.
full_resync = self.generation is None or (
time.monotonic() - self.last_contact > self.remote.refresh_interval
)
resume = {} if full_resync else {"generation": self.generation, "seq": self.seq}
async with websockets.connect(
ws_url, additional_headers={"Authorization": f"Bearer {self.remote.token}"}
) as ws:
hello = msgspec.json.decode(await ws.recv())
if hello.get("type") != "hello":
raise ValueError("sync: expected hello")
await ws.send(msgspec.json.encode({"type": "resume", **resume}))
sender = asyncio.create_task(self._send_loop(ws))
staging: DB | None = None
try:
while True:
message = msgspec.json.decode(await ws.recv())
self.last_contact = time.monotonic()
mtype = message.get("type")
if mtype == "snapshot":
if staging is None:
staging = DB()
for key, fields in message["items"]:
_apply(staging, message["table"], key, "upsert", fields)
elif mtype == "event":
if staging is not None or (
self.generation is not None
and message["seq"] != self.seq + 1
):
raise ValueError("sync: event out of order")
self.seq = message["seq"]
_apply(
self.db,
message["table"],
message["key"],
message["op"],
message.get("fields"),
)
elif mtype == "ready":
if staging is not None:
self.db = staging
staging = None
attach_stores()
self.generation = hello["generation"]
self.seq = message["seq"]
finally:
sender.cancel()
with asyncio.suppress(asyncio.CancelledError):
await sender
async def _send_loop(self, ws) -> None:
while True:
self._refresh_signal.clear()
while self._pending_refresh:
_, message = self._pending_refresh.popitem()
await ws.send(msgspec.json.encode(message))
await self._refresh_signal.wait()
class SatelliteManager:
"""Replicas keyed by remote URL; domains sharing a remote share one."""
def __init__(self):
self.replicas: dict[str, RemoteReplica] = {}
def replica_for(self, domain: domains.Domain) -> RemoteReplica | None:
if domain.remote is None:
return None
return self.replicas.get(domain.remote.url)
async def start(self) -> None:
domains.add_rebuild_listener(self.reconcile)
await self.reconcile(domains.registry())
async def stop(self) -> None:
domains.remove_rebuild_listener(self.reconcile)
for replica in self.replicas.values():
await replica.stop()
self.replicas.clear()
async def reconcile(self, registry: domains.DomainRegistry) -> None:
"""Attach stores and start/stop replicas to match the config."""
wanted = {}
for domain in registry.domains:
if domain.remote is not None:
wanted.setdefault(domain.remote.url, domain.remote)
for url in list(self.replicas):
if url not in wanted:
await self.replicas.pop(url).stop()
for url, remote in wanted.items():
replica = self.replicas.get(url)
if replica is None or replica.remote != remote:
if replica is not None:
await replica.stop()
replica = RemoteReplica(remote)
self.replicas[url] = replica
await replica.start()
attach_stores()
manager = SatelliteManager()
def attach_stores() -> None:
"""Attach each remote domain's store to its replica."""
for domain in domains.registry().domains:
replica = manager.replica_for(domain)
if replica is not None:
domain.store = replica.db