"""service_seen — the learned roster of FabledCurator's own moving parts. Nothing else in this application knows what is SUPPOSED to be running. `celery inspect` reports the workers that answer, so a stopped worker is a shorter list rather than a red light, and Postgres and Redis have no representation at all. That is why the only place an operator could see a dead service was Portainer, which knows the intended set (milestone 365). This table is the memory that makes an absence observable: every part that has ever checked in, and when it last did. A row that stops advancing is a part that stopped. ## Why the key is not the hostname `_read_workers_sync()` returns celery's worker names, which here are `celery@`. Those are minted fresh on every deploy. Keyed on them, this table would record a death and a birth every time the stack is updated — and a status page that goes red on every deploy is a status page nobody reads, which is worse than not having one. So a celery role is keyed on its **queue set**, which is assigned per role in docker-compose.yml (`CELERY_QUEUES`) and survives container replacement: default,import,thumbnail,download -> worker maintenance,scan -> scheduler (celery worker --beat) ml -> ml-worker Two replicas of one role share a queue set and are therefore ONE row — which is right, because the question being answered is "is that role being served", not "how many containers exist". The replica count and their hostnames go in `details`, where they can change without the identity changing. The GPU agent is keyed on its `agent_id`, the identity its lease protocol already uses (`api/gpu.py`). ## What is NOT in here Postgres and Redis. They are always expected and never learned, and a last-seen for them would be actively misleading — that one answered thirty seconds ago says nothing about now. They are probed live at request time. ## kind Plain `String`, not a Postgres ENUM and not CHECK-gated, matching `gpu_job.status` and `backup_run.status`. The value set here is expected to grow as parts are added, and a constraint swap per new kind (rule 36) would be cost with no invariant behind it. celery — a worker role, keyed on its queue set agent — a GPU agent, keyed on its agent_id """ from datetime import datetime from sqlalchemy import JSON, DateTime, String, func from sqlalchemy.orm import Mapped, mapped_column from .base import Base class ServiceSeen(Base): __tablename__ = "service_seen" # No indexes beyond the primary key, deliberately. This table holds one row # per moving part — a handful, forever — so every query against it is a # full read of a few rows and an index would be write cost buying nothing # (the lesson of #3301, which removed seven redundant ones). key: Mapped[str] = mapped_column(String(128), primary_key=True) kind: Mapped[str] = mapped_column(String(16), nullable=False) # What to call it in the UI. Derived from the queue set where it is # recognised, and falling back to the raw queue list where it is not — a # deployment that slices its queues differently should still show something # true rather than a name this code invented for it. display_name: Mapped[str] = mapped_column(String(64), nullable=False) first_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) last_seen_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), nullable=False, server_default=func.now(), ) # The parts that change without changing identity: replica hostnames, # active task counts, the queues actually being served. Kept as a blob # because it is displayed and never queried — giving it columns would # invite filtering on it, which is what the activity endpoints are for. details: Mapped[dict] = mapped_column(JSON, nullable=False, default=dict)