state-hub/api/edge/read_cache.py
tegwick 1cf949bda4
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 51s
feat(edge): add offline read cache for allowlisted State Hub GET routes
The edge relay now persists successful GET responses and serves them with
stale markers when upstream is unreachable. Extend Forgejo image workflow
path filters so api changes trigger registry publishes.
2026-07-09 01:04:15 +02:00

152 lines
No EOL
4.5 KiB
Python

from __future__ import annotations
import os
import sqlite3
import stat
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlencode
DEFAULT_READ_CACHE_PATH = Path(
os.environ.get("STATEHUB_READ_CACHE_PATH", "~/.statehub/edge-read-cache.sqlite3")
).expanduser()
MAX_CACHE_ENTRY_BYTES = 1024 * 1024
CACHEABLE_GET_PREFIXES = (
"/state/",
"/workplans/",
"/messages/",
"/decisions/",
"/tasks/",
"/progress/",
"/sbom/",
"/repos",
"/legacy-meter/",
)
@dataclass(frozen=True)
class ReadCacheEntry:
cache_key: str
status_code: int
content: bytes
content_type: str | None
cached_at: str
def default_read_cache_path() -> Path:
return DEFAULT_READ_CACHE_PATH
def utcnow() -> str:
return datetime.now(tz=timezone.utc).isoformat()
def build_cache_key(method: str, path: str, query_items: list[tuple[str, str]]) -> str:
normalized_path = path if path.startswith("/") else f"/{path}"
query = urlencode(sorted(query_items))
if query:
return f"{method.upper()}:{normalized_path}?{query}"
return f"{method.upper()}:{normalized_path}"
def is_cacheable_get(path: str) -> bool:
normalized = path if path.startswith("/") else f"/{path}"
if normalized.startswith("/edge/"):
return False
return any(normalized.startswith(prefix) for prefix in CACHEABLE_GET_PREFIXES)
class ReadCacheStore:
def __init__(self, path: str | Path | None = None) -> None:
self.path = Path(path).expanduser() if path is not None else default_read_cache_path()
self.path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
self._chmod_private()
def _connect(self) -> sqlite3.Connection:
conn = sqlite3.connect(self.path)
conn.row_factory = sqlite3.Row
return conn
def _init_db(self) -> None:
with self._connect() as conn:
conn.execute(
"""
CREATE TABLE IF NOT EXISTS read_cache_entries (
cache_key TEXT PRIMARY KEY,
status_code INTEGER NOT NULL,
content BLOB NOT NULL,
content_type TEXT,
cached_at TEXT NOT NULL
)
"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS ix_read_cache_cached_at ON read_cache_entries(cached_at)"
)
conn.commit()
def _chmod_private(self) -> None:
try:
os.chmod(self.path, stat.S_IRUSR | stat.S_IWUSR)
except OSError:
pass
def put(
self,
*,
cache_key: str,
status_code: int,
content: bytes,
content_type: str | None,
) -> None:
if len(content) > MAX_CACHE_ENTRY_BYTES:
return
now = utcnow()
with self._connect() as conn:
conn.execute(
"""
INSERT INTO read_cache_entries (
cache_key, status_code, content, content_type, cached_at
) VALUES (?, ?, ?, ?, ?)
ON CONFLICT(cache_key) DO UPDATE SET
status_code = excluded.status_code,
content = excluded.content,
content_type = excluded.content_type,
cached_at = excluded.cached_at
""",
(cache_key, status_code, content, content_type, now),
)
conn.commit()
def get(self, cache_key: str) -> ReadCacheEntry | None:
with self._connect() as conn:
row = conn.execute(
"SELECT * FROM read_cache_entries WHERE cache_key = ?",
(cache_key,),
).fetchone()
if row is None:
return None
return ReadCacheEntry(
cache_key=row["cache_key"],
status_code=row["status_code"],
content=row["content"],
content_type=row["content_type"],
cached_at=row["cached_at"],
)
def summary(self) -> dict[str, int | str | None]:
with self._connect() as conn:
row = conn.execute(
"""
SELECT COUNT(*) AS entry_count,
MIN(cached_at) AS oldest_cached_at
FROM read_cache_entries
"""
).fetchone()
return {
"entry_count": int(row["entry_count"] or 0),
"oldest_cached_at": row["oldest_cached_at"],
}