Bind visualization status to snapshot freshness
This commit is contained in:
parent
24bd13f9d9
commit
176b2d2784
9 changed files with 627 additions and 43 deletions
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
|||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import plistlib
|
||||
import secrets
|
||||
|
|
@ -25,15 +26,27 @@ from typing import BinaryIO, cast
|
|||
|
||||
from .errors import DocForgeError
|
||||
from .index import ProjectIndex
|
||||
from .models import IncrementalStateProject
|
||||
from .project import project_root_fingerprint
|
||||
from .telemetry import increment, stage
|
||||
from .visualization import VISUALIZATION_TEMPLATE, VisualizationIndexSnapshot
|
||||
|
||||
MANAGER_PROTOCOL = "docforge-viewer-manager@1"
|
||||
MANAGER_RUNTIME = "viewer-manager@1"
|
||||
MANAGER_PROTOCOL = "docforge-viewer-manager@2"
|
||||
MANAGER_RUNTIME = "viewer-manager@2"
|
||||
DEFAULT_IDLE_TIMEOUT_SECONDS = 3600.0
|
||||
DEFAULT_CHECK_INTERVAL_SECONDS = 30.0
|
||||
MAX_MESSAGE_BYTES = 1_000_000
|
||||
WORKER_SNAPSHOT_KEYS = frozenset(
|
||||
{
|
||||
"project_id",
|
||||
"project_root_fingerprint",
|
||||
"revision",
|
||||
"source_hash",
|
||||
"adapter",
|
||||
"node_count",
|
||||
"edge_count",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def default_runtime_root() -> Path:
|
||||
|
|
@ -225,6 +238,12 @@ class _ManagedWorker:
|
|||
last_activity_at: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _WorkerHealth:
|
||||
last_activity_at: float
|
||||
index_state: str
|
||||
|
||||
|
||||
class ViewerManager:
|
||||
"""Own project viewer processes behind an authenticated loopback control API."""
|
||||
|
||||
|
|
@ -426,12 +445,13 @@ class ViewerManager:
|
|||
self._workers.pop(key, None)
|
||||
self._stop_worker(worker)
|
||||
return {"status": "ok", "state": "not_running"}
|
||||
worker.last_activity_at = activity
|
||||
worker.last_activity_at = activity.last_activity_at
|
||||
return {
|
||||
"status": "ok",
|
||||
"state": "running",
|
||||
"snapshot": dict(worker.snapshot),
|
||||
"idle_seconds": max(0, int(time.time() - activity)),
|
||||
"index_state": activity.index_state,
|
||||
"idle_seconds": max(0, int(time.time() - activity.last_activity_at)),
|
||||
"idle_timeout_seconds": self.idle_timeout_seconds,
|
||||
}
|
||||
|
||||
|
|
@ -501,9 +521,9 @@ class ViewerManager:
|
|||
if worker.snapshot != snapshot.identity or worker.process.poll() is not None:
|
||||
return False
|
||||
activity = self._health(worker)
|
||||
if activity is None:
|
||||
if activity is None or activity.index_state != "current":
|
||||
return False
|
||||
worker.last_activity_at = activity
|
||||
worker.last_activity_at = activity.last_activity_at
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -518,7 +538,7 @@ class ViewerManager:
|
|||
worker.process.wait(timeout=2)
|
||||
|
||||
@staticmethod
|
||||
def _health(worker: _ManagedWorker) -> float | None:
|
||||
def _health(worker: _ManagedWorker) -> _WorkerHealth | None:
|
||||
request = urllib.request.Request(
|
||||
f"http://127.0.0.1:{worker.port}/{worker.token}/api/health",
|
||||
headers={"Accept": "application/json"},
|
||||
|
|
@ -531,10 +551,25 @@ class ViewerManager:
|
|||
if not isinstance(payload, dict):
|
||||
return None
|
||||
payload = cast(dict[str, object], payload)
|
||||
if payload.get("viewer") != "alive":
|
||||
if payload.get("status") != "ok" or payload.get("viewer") != "alive":
|
||||
return None
|
||||
if frozenset(worker.snapshot) != WORKER_SNAPSHOT_KEYS:
|
||||
return None
|
||||
if any(payload.get(key) != value for key, value in worker.snapshot.items()):
|
||||
return None
|
||||
activity = payload.get("last_activity_at")
|
||||
return float(activity) if isinstance(activity, int | float) else None
|
||||
index_state = payload.get("index_state")
|
||||
if (
|
||||
isinstance(activity, bool)
|
||||
or not isinstance(activity, int | float)
|
||||
or not math.isfinite(activity)
|
||||
or index_state not in {"current", "stale"}
|
||||
):
|
||||
return None
|
||||
return _WorkerHealth(
|
||||
last_activity_at=float(activity),
|
||||
index_state=cast(str, index_state),
|
||||
)
|
||||
|
||||
def _result(
|
||||
self,
|
||||
|
|
@ -575,11 +610,11 @@ class ViewerManager:
|
|||
with self._lock:
|
||||
expired: list[tuple[str, _ManagedWorker]] = []
|
||||
for key, worker in self._workers.items():
|
||||
activity = self._health(worker)
|
||||
if activity is None or activity < cutoff:
|
||||
health = self._health(worker)
|
||||
if health is None or health.last_activity_at < cutoff:
|
||||
expired.append((key, worker))
|
||||
else:
|
||||
worker.last_activity_at = activity
|
||||
worker.last_activity_at = health.last_activity_at
|
||||
for key, worker in expired:
|
||||
self._workers.pop(key, None)
|
||||
self._stop_worker(worker)
|
||||
|
|
@ -637,7 +672,77 @@ class ViewerManagerClient:
|
|||
|
||||
def status(self) -> dict[str, object]:
|
||||
with stage("visualization.status"):
|
||||
return self._lifecycle_request("status")
|
||||
response = self._lifecycle_request("status")
|
||||
lifecycle = response.get("state")
|
||||
if lifecycle not in {"running", "not_running"}:
|
||||
raise DocForgeError(
|
||||
"visualization_unavailable",
|
||||
"Viewer manager returned an invalid lifecycle state",
|
||||
)
|
||||
if lifecycle != "running":
|
||||
return {
|
||||
**response,
|
||||
"revision": "unknown",
|
||||
"source_hash": None,
|
||||
"snapshot_state": "unknown",
|
||||
"staleness": "unknown",
|
||||
"freshness": {
|
||||
"index": "unknown",
|
||||
"source": "unknown",
|
||||
},
|
||||
}
|
||||
index_value = response.get("index_state")
|
||||
index_state = (
|
||||
cast(str, index_value) if index_value in {"current", "stale"} else "unknown"
|
||||
)
|
||||
snapshot_value = response.get("snapshot")
|
||||
snapshot_payload: dict[str, object] = (
|
||||
cast(dict[str, object], snapshot_value) if isinstance(snapshot_value, dict) else {}
|
||||
)
|
||||
source_state = self._source_state(snapshot_payload)
|
||||
revision = snapshot_payload.get("revision")
|
||||
source_hash = snapshot_payload.get("source_hash")
|
||||
snapshot_state = (
|
||||
"stale"
|
||||
if "stale" in {index_state, source_state}
|
||||
else (
|
||||
"current"
|
||||
if index_state == "current" and source_state == "current"
|
||||
else "unknown"
|
||||
)
|
||||
)
|
||||
return {
|
||||
**response,
|
||||
"revision": revision if isinstance(revision, str) else "unknown",
|
||||
"source_hash": source_hash if isinstance(source_hash, str) else None,
|
||||
"snapshot_state": snapshot_state,
|
||||
"staleness": snapshot_state,
|
||||
"freshness": {
|
||||
"index": index_state,
|
||||
"source": source_state,
|
||||
},
|
||||
}
|
||||
|
||||
def _source_state(self, snapshot: object) -> str:
|
||||
project = self.index.project
|
||||
if not isinstance(project, IncrementalStateProject) or not isinstance(snapshot, dict):
|
||||
return "unknown"
|
||||
snapshot = cast(dict[str, object], snapshot)
|
||||
revision = snapshot.get("revision")
|
||||
source_hash = snapshot.get("source_hash")
|
||||
if not isinstance(revision, str) or not isinstance(source_hash, str):
|
||||
return "unknown"
|
||||
try:
|
||||
state = project.incremental_state()
|
||||
except (DocForgeError, OSError, RuntimeError, TypeError, ValueError):
|
||||
return "unknown"
|
||||
if state is None:
|
||||
return "unknown"
|
||||
return (
|
||||
"current"
|
||||
if state.revision == revision and state.source_hash == source_hash
|
||||
else "stale"
|
||||
)
|
||||
|
||||
def _lifecycle_request(self, action: str) -> dict[str, object]:
|
||||
descriptor = self.index.project.descriptor
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import secrets
|
|||
import signal
|
||||
import socket
|
||||
import sqlite3
|
||||
import stat
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
|
|
@ -73,6 +74,9 @@ LEASE_MONITOR_INTERVAL_SECONDS = 1.0
|
|||
VISUALIZATION_REGISTRY_NAME = ".visualization.json"
|
||||
VISUALIZATION_LOCK_NAME = ".visualization.lock"
|
||||
VISUALIZATION_RUNTIME = "persistent-worker@1"
|
||||
VISUALIZATION_SNAPSHOT_SCHEMA_VERSION = 1
|
||||
|
||||
IndexSignature = tuple[int, int, int, int, int]
|
||||
|
||||
|
||||
class _VisualizationHttpServer(ThreadingHTTPServer):
|
||||
|
|
@ -103,10 +107,13 @@ class VisualizationIndexSnapshot:
|
|||
self.max_depth = index.project.descriptor.limits.max_traversal_depth
|
||||
self.identity: dict[str, object] = {key: checked[key] for key in self._IDENTITY_KEYS}
|
||||
self._stat = self._safe_stat()
|
||||
self._validate_snapshot()
|
||||
|
||||
@classmethod
|
||||
def from_spec(cls, spec: dict[str, object]) -> VisualizationIndexSnapshot:
|
||||
snapshot = cls.__new__(cls)
|
||||
if spec.get("schema_version") != VISUALIZATION_SNAPSHOT_SCHEMA_VERSION:
|
||||
raise DocForgeError("invalid_index", "Visualization snapshot version is invalid")
|
||||
path = spec["path"]
|
||||
title = spec["title"]
|
||||
project_root = spec["project_root"]
|
||||
|
|
@ -117,11 +124,16 @@ class VisualizationIndexSnapshot:
|
|||
if (
|
||||
not isinstance(path, str)
|
||||
or not isinstance(title, str)
|
||||
or not title
|
||||
or not isinstance(project_root, str)
|
||||
or type(max_source_bytes) is not int
|
||||
or max_source_bytes < 1
|
||||
or type(max_query_chars) is not int
|
||||
or max_query_chars < 1
|
||||
or type(max_results) is not int
|
||||
or max_results < 1
|
||||
or type(max_depth) is not int
|
||||
or max_depth < 1
|
||||
):
|
||||
raise DocForgeError("invalid_index", "Visualization snapshot is invalid")
|
||||
snapshot.path = Path(path)
|
||||
|
|
@ -131,6 +143,12 @@ class VisualizationIndexSnapshot:
|
|||
raise DocForgeError("invalid_index", "Visualization project root is invalid") from error
|
||||
if not snapshot.project_root.is_dir():
|
||||
raise DocForgeError("invalid_index", "Visualization project root is invalid")
|
||||
if not snapshot.path.is_absolute():
|
||||
raise DocForgeError("invalid_index", "Visualization index path is invalid")
|
||||
try:
|
||||
snapshot.path.relative_to(snapshot.project_root)
|
||||
except ValueError as error:
|
||||
raise DocForgeError("invalid_index", "Visualization index path is invalid") from error
|
||||
snapshot.title = title
|
||||
snapshot.max_source_bytes = max_source_bytes
|
||||
snapshot.max_query_chars = max_query_chars
|
||||
|
|
@ -139,13 +157,59 @@ class VisualizationIndexSnapshot:
|
|||
identity = spec["identity"]
|
||||
if not isinstance(identity, dict):
|
||||
raise DocForgeError("invalid_index", "Visualization identity is invalid")
|
||||
typed_identity = cast(dict[str, object], identity)
|
||||
snapshot.identity = {key: typed_identity[key] for key in cls._IDENTITY_KEYS}
|
||||
snapshot._stat = snapshot._safe_stat()
|
||||
snapshot.identity = cls._parse_identity(
|
||||
cast(dict[str, object], identity),
|
||||
snapshot.project_root,
|
||||
)
|
||||
snapshot._stat = cls._parse_index_signature(spec.get("index_signature"))
|
||||
if snapshot.index_state() != "current":
|
||||
raise DocForgeError(
|
||||
"visualization_stale",
|
||||
"The validated index changed before the visualization worker started",
|
||||
)
|
||||
snapshot._validate_snapshot()
|
||||
return snapshot
|
||||
|
||||
@classmethod
|
||||
def _parse_identity(
|
||||
cls,
|
||||
value: dict[str, object],
|
||||
project_root: Path,
|
||||
) -> dict[str, object]:
|
||||
if set(value) != set(cls._IDENTITY_KEYS):
|
||||
raise DocForgeError("invalid_index", "Visualization identity is invalid")
|
||||
project_id = value.get("project_id")
|
||||
fingerprint = value.get("project_root_fingerprint")
|
||||
revision = value.get("revision")
|
||||
source_hash = value.get("source_hash")
|
||||
adapter = value.get("adapter")
|
||||
node_count = value.get("node_count")
|
||||
edge_count = value.get("edge_count")
|
||||
if (
|
||||
not isinstance(project_id, str)
|
||||
or not project_id
|
||||
or not isinstance(fingerprint, str)
|
||||
or len(fingerprint) != 16
|
||||
or any(character not in "0123456789abcdef" for character in fingerprint)
|
||||
or fingerprint != project_root_fingerprint(project_root)
|
||||
or not isinstance(revision, str)
|
||||
or not revision
|
||||
or not isinstance(source_hash, str)
|
||||
or len(source_hash) != 64
|
||||
or any(character not in "0123456789abcdef" for character in source_hash)
|
||||
or not isinstance(adapter, str)
|
||||
or not adapter
|
||||
or type(node_count) is not int
|
||||
or node_count < 0
|
||||
or type(edge_count) is not int
|
||||
or edge_count < 0
|
||||
):
|
||||
raise DocForgeError("invalid_index", "Visualization identity is invalid")
|
||||
return {key: value[key] for key in cls._IDENTITY_KEYS}
|
||||
|
||||
def spec(self) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": VISUALIZATION_SNAPSHOT_SCHEMA_VERSION,
|
||||
"path": str(self.path),
|
||||
"project_root": str(self.project_root),
|
||||
"title": self.title,
|
||||
|
|
@ -154,8 +218,44 @@ class VisualizationIndexSnapshot:
|
|||
"max_results": self.max_results,
|
||||
"max_depth": self.max_depth,
|
||||
"identity": dict(self.identity),
|
||||
"index_signature": {
|
||||
"schema_version": 1,
|
||||
"device": self._stat[0],
|
||||
"inode": self._stat[1],
|
||||
"size": self._stat[2],
|
||||
"mtime_ns": self._stat[3],
|
||||
"ctime_ns": self._stat[4],
|
||||
},
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _parse_index_signature(value: object) -> IndexSignature:
|
||||
if not isinstance(value, dict):
|
||||
raise DocForgeError("invalid_index", "Visualization index signature is invalid")
|
||||
payload = cast(dict[str, object], value)
|
||||
if payload.get("schema_version") != 1:
|
||||
raise DocForgeError("invalid_index", "Visualization index signature is invalid")
|
||||
fields = ("device", "inode", "size", "mtime_ns", "ctime_ns")
|
||||
values: list[int] = []
|
||||
for field in fields:
|
||||
item = payload.get(field)
|
||||
if type(item) is not int or item < 0:
|
||||
raise DocForgeError("invalid_index", "Visualization index signature is invalid")
|
||||
values.append(item)
|
||||
return cast(IndexSignature, tuple(values))
|
||||
|
||||
def index_state(self) -> str:
|
||||
"""Return cheap publication freshness without opening SQLite."""
|
||||
|
||||
try:
|
||||
return "current" if self._safe_stat() == self._stat else "stale"
|
||||
except DocForgeError:
|
||||
return "stale"
|
||||
|
||||
def _validate_snapshot(self) -> None:
|
||||
with self._connection():
|
||||
pass
|
||||
|
||||
def overview(self) -> dict[str, object]:
|
||||
with self._connection() as connection:
|
||||
return self._result(
|
||||
|
|
@ -749,15 +849,26 @@ class VisualizationIndexSnapshot:
|
|||
raise DocForgeError("invalid_limit", "Result limit is outside the configured range")
|
||||
return value
|
||||
|
||||
def _safe_stat(self) -> tuple[int, int, int, int]:
|
||||
if (
|
||||
self.path.is_symlink()
|
||||
or not self.path.is_file()
|
||||
or self.path.resolve(strict=True) != self.path
|
||||
):
|
||||
raise DocForgeError("missing_index", "Validated visualization index is unavailable")
|
||||
stat = self.path.stat()
|
||||
return (stat.st_dev, stat.st_ino, stat.st_size, stat.st_mtime_ns)
|
||||
def _safe_stat(self) -> IndexSignature:
|
||||
try:
|
||||
status = self.path.lstat()
|
||||
if not stat.S_ISREG(status.st_mode) or self.path.resolve(strict=True) != self.path:
|
||||
raise DocForgeError(
|
||||
"missing_index",
|
||||
"Validated visualization index is unavailable",
|
||||
)
|
||||
except OSError as error:
|
||||
raise DocForgeError(
|
||||
"missing_index",
|
||||
"Validated visualization index is unavailable",
|
||||
) from error
|
||||
return (
|
||||
status.st_dev,
|
||||
status.st_ino,
|
||||
status.st_size,
|
||||
status.st_mtime_ns,
|
||||
status.st_ctime_ns,
|
||||
)
|
||||
|
||||
@contextmanager
|
||||
def _connection(self) -> Generator[sqlite3.Connection, None, None]:
|
||||
|
|
@ -1074,7 +1185,11 @@ class VisualizationRunner:
|
|||
if parsed.path == f"{prefix}/api/health":
|
||||
with self._lock:
|
||||
last_activity = self._activity_last_seen
|
||||
payload = reader.result(viewer="alive", last_activity_at=last_activity)
|
||||
payload = reader.result(
|
||||
viewer="alive",
|
||||
last_activity_at=last_activity,
|
||||
index_state=reader.index_state(),
|
||||
)
|
||||
elif parsed.path == f"{prefix}/api/overview":
|
||||
self._touch_lease()
|
||||
payload = reader.overview()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue