Files
minerops/backend/app/locations/repository.py
T

232 lines
9.8 KiB
Python

"""Set-based PostgreSQL reads for Mine Map v1."""
from collections.abc import Callable, Iterator
from contextlib import contextmanager
from typing import Any
from uuid import UUID
import psycopg
from psycopg.rows import dict_row
from app.config import settings
ConnectionFactory = Callable[[], psycopg.Connection]
ACTIVE_REPAIR_STATUSES = (
"OPEN",
"IN_PROGRESS",
"WAITING_FOR_PARTS",
"READY_FOR_TEST",
)
def default_connection_factory() -> psycopg.Connection:
return psycopg.connect(
host=settings.postgres_host,
port=settings.postgres_port,
dbname=settings.postgres_db,
user=settings.postgres_user,
password=settings.postgres_password,
connect_timeout=5,
)
LATEST_TELEMETRY_SQL = """
LEFT JOIN LATERAL (
SELECT snapshots.observed_at,
ROUND((snapshots.mhs_av / 1000000.0)::numeric, 2)::float
AS hashrate_ths,
ROUND(snapshots.power_w::numeric, 2)::float AS power_w,
ROUND(snapshots.chip_temp_max_c::numeric, 2)::float
AS chip_temp_max_c,
ROUND(snapshots.chip_temp_avg_c::numeric, 2)::float
AS chip_temp_avg_c,
snapshots.hash_stable, snapshots.pool_rejected_percent
FROM miner_status_snapshots snapshots
WHERE snapshots.miner_id = miners.id
ORDER BY snapshots.observed_at DESC, snapshots.id DESC
LIMIT 1
) latest ON true
"""
ACTIVE_REPAIR_SQL = """
LEFT JOIN LATERAL (
SELECT repairs.id, repairs.status, repairs.reason
FROM repairs
WHERE repairs.miner_id = miners.id AND repairs.status = ANY(%s)
ORDER BY repairs.opened_at, repairs.id
LIMIT 1
) active_repair ON true
"""
class LocationMapRepository:
"""Own all persistence used by the Mine Map read model."""
def __init__(
self,
connection_factory: ConnectionFactory | None = None,
*,
connection: psycopg.Connection | None = None,
):
self._connection_factory = connection_factory or default_connection_factory
self._connection = connection
@contextmanager
def _connection_scope(self) -> Iterator[psycopg.Connection]:
if self._connection is not None:
yield self._connection
return
connection = self._connection_factory()
try:
yield connection
finally:
connection.close()
def read_map(
self,
*,
site_id: UUID | None = None,
room_id: UUID | None = None,
) -> dict[str, list[dict[str, Any]]]:
position_where = []
position_params: list[Any] = [list(ACTIVE_REPAIR_STATUSES)]
virtual_where = [
"COALESCE(mls.asset_state, 'waiting_placement') = ANY(%s)",
"NOT EXISTS (SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)",
]
virtual_params: list[Any] = [
list(ACTIVE_REPAIR_STATUSES),
["waiting_placement", "storage", "repair", "decommissioned"],
]
if site_id is not None:
position_where.append("rooms.site_id = %s")
position_params.append(site_id)
virtual_where.append("miners.site_id = %s")
virtual_params.append(site_id)
if room_id is not None:
position_where.append("lp.room_id = %s")
position_params.append(room_id)
virtual_where.append("miners.room_id = %s")
virtual_params.append(room_id)
position_filter = (
"WHERE " + " AND ".join(position_where) if position_where else ""
)
virtual_filter = "WHERE " + " AND ".join(virtual_where)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute("SELECT id, name FROM sites ORDER BY lower(name), id")
sites = [dict(row) for row in cur.fetchall()]
cur.execute(
"""
SELECT rooms.id, rooms.site_id, sites.name AS site_name,
rooms.name, rooms.sort_order
FROM rooms JOIN sites ON sites.id = rooms.site_id
ORDER BY lower(sites.name), rooms.sort_order,
lower(rooms.name), rooms.id
"""
)
rooms = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT lp.id AS location_position_id, sites.id AS site_id,
sites.name AS site_name, rooms.id AS room_id,
COALESCE(rooms.name, lp.room_name) AS room_name,
lp.rack_name, lp.shelf_name, lp.shelf_index,
lp.position_index, lp.position_label, lp.position_kind,
lp.position_status, (miners.id IS NOT NULL) AS occupied,
lp.expected_mac, lp.equipment_label, lp.notes,
lp.source_file, lp.source_cell, lp.conflict_reason,
miners.id AS miner_id, miners.asset_tag AS asset_id,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
COALESCE(mls.asset_state, 'unassigned') AS asset_state,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable, latest.pool_rejected_percent,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM location_positions lp
LEFT JOIN rooms ON rooms.id = lp.room_id
LEFT JOIN sites ON sites.id = rooms.site_id
LEFT JOIN miners ON miners.id = lp.miner_id
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{position_filter}
ORDER BY lower(sites.name) NULLS LAST,
rooms.sort_order NULLS LAST,
lower(COALESCE(rooms.name, lp.room_name)),
lower(lp.rack_name), lp.shelf_index NULLS LAST,
lower(lp.shelf_name), lp.position_index, lp.id
""",
position_params,
)
positions = [dict(row) for row in cur.fetchall()]
cur.execute(
f"""
SELECT miners.id AS miner_id, miners.site_id, miners.room_id,
miners.asset_tag AS asset_id,
COALESCE(mls.asset_state, 'waiting_placement') AS asset_state,
miners.current_mac, host(miners.current_ip) AS current_ip,
miners.status AS miner_status, miners.status, miners.hostname,
miners.miner_sn, miners.power_sn, miners.model,
miners.miner_type, miners.firmware_version,
miners.lifecycle_status, miners.last_seen_at,
latest.observed_at, latest.hashrate_ths, latest.power_w,
latest.chip_temp_max_c, latest.chip_temp_avg_c,
latest.hash_stable,
active_repair.id AS active_repair_id,
active_repair.status AS active_repair_status,
active_repair.reason AS active_repair_reason
FROM miners
LEFT JOIN miner_location_states mls ON mls.miner_id = miners.id
{LATEST_TELEMETRY_SQL}
{ACTIVE_REPAIR_SQL}
{virtual_filter}
ORDER BY asset_state, lower(miners.asset_tag), miners.id
""",
virtual_params,
)
virtual_miners = [dict(row) for row in cur.fetchall()]
return {
"sites": sites,
"rooms": rooms,
"positions": positions,
"virtual_miners": virtual_miners,
}
def summarize_locations(self, *, include_miner_counts: bool) -> dict[str, Any]:
miner_counts = (
"count(DISTINCT lp.miner_id)::integer AS mapped_miners, "
"(SELECT count(*)::integer FROM miners WHERE NOT EXISTS ("
"SELECT 1 FROM location_positions assigned "
"WHERE assigned.miner_id = miners.id)) AS unmapped_miners"
if include_miner_counts
else "NULL::integer AS mapped_miners, NULL::integer AS unmapped_miners"
)
with self._connection_scope() as connection:
with connection.cursor(row_factory=dict_row) as cur:
cur.execute(
f"""
SELECT (SELECT count(*)::integer FROM sites) AS sites,
(SELECT count(*)::integer FROM rooms) AS rooms,
count(lp.id)::integer AS positions,
count(lp.id) FILTER (
WHERE lp.miner_id IS NOT NULL
)::integer AS occupied_positions,
count(lp.id) FILTER (
WHERE lp.miner_id IS NULL
)::integer AS empty_positions,
{miner_counts}
FROM location_positions AS lp
"""
)
return dict(cur.fetchone())