feat(db): add DeckyLifecycle table for async deploy/mutate tracking
One row per (decky, operation) attempt. State machine: pending -> running -> succeeded | failed (+ error text). Rows are append-only after terminal; retries write a new row. Sibling of DeckyShard rather than a rework -- DeckyShard tracks runtime container state observed via heartbeat, this tracks operation lifecycle. New table, UUID PK. Adds BaseRepository abstract methods (create_lifecycle, update_lifecycle, get_lifecycle_by_ids, find_open_lifecycle, sweep_stale_lifecycle) with SQLModelRepository mixin impl. Backbone for the upcoming 202-Accepted async API.
This commit is contained in:
@@ -42,6 +42,7 @@ from decnet.web.db.sqlmodel_repo.campaigns import CampaignsMixin
|
||||
from decnet.web.db.sqlmodel_repo.canary import CanaryMixin
|
||||
from decnet.web.db.sqlmodel_repo.credentials import CredentialsMixin
|
||||
from decnet.web.db.sqlmodel_repo.deckies import DeckiesMixin
|
||||
from decnet.web.db.sqlmodel_repo.decky_lifecycle import LifecycleMixin
|
||||
from decnet.web.db.sqlmodel_repo.fleet import FleetMixin
|
||||
from decnet.web.db.sqlmodel_repo.identities import IdentitiesMixin
|
||||
from decnet.web.db.sqlmodel_repo.logs import LogsMixin
|
||||
@@ -66,6 +67,7 @@ class SQLModelRepository(
|
||||
CanaryMixin,
|
||||
CredentialsMixin,
|
||||
DeckiesMixin,
|
||||
LifecycleMixin,
|
||||
FleetMixin,
|
||||
IdentitiesMixin,
|
||||
LogsMixin,
|
||||
|
||||
106
decnet/web/db/sqlmodel_repo/decky_lifecycle.py
Normal file
106
decnet/web/db/sqlmodel_repo/decky_lifecycle.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""DeckyLifecycle CRUD + sweep.
|
||||
|
||||
One row per (decky, operation) attempt. States: pending → running →
|
||||
succeeded | failed. Mixed into ``SQLModelRepository`` for both SQLite
|
||||
and MySQL via MRO composition.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid as _uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from sqlalchemy import asc, select, update
|
||||
|
||||
from decnet.web.db.models import DeckyLifecycle
|
||||
from decnet.web.db.sqlmodel_repo._helpers import _MixinBase
|
||||
|
||||
|
||||
_TERMINAL = ("succeeded", "failed")
|
||||
|
||||
|
||||
class LifecycleMixin(_MixinBase):
|
||||
"""Mixin: composed onto ``SQLModelRepository``."""
|
||||
|
||||
async def create_lifecycle(self, data: dict[str, Any]) -> str:
|
||||
payload = dict(data)
|
||||
payload.setdefault("id", str(_uuid.uuid4()))
|
||||
payload.setdefault("status", "pending")
|
||||
now = datetime.now(timezone.utc)
|
||||
payload.setdefault("started_at", now)
|
||||
payload["updated_at"] = now
|
||||
async with self._session() as session:
|
||||
session.add(DeckyLifecycle(**payload))
|
||||
await session.commit()
|
||||
return str(payload["id"])
|
||||
|
||||
async def update_lifecycle(
|
||||
self,
|
||||
lifecycle_id: str,
|
||||
fields: dict[str, Any],
|
||||
) -> None:
|
||||
payload = dict(fields)
|
||||
payload["updated_at"] = datetime.now(timezone.utc)
|
||||
if payload.get("status") in _TERMINAL and "completed_at" not in payload:
|
||||
payload["completed_at"] = payload["updated_at"]
|
||||
async with self._session() as session:
|
||||
await session.execute(
|
||||
update(DeckyLifecycle)
|
||||
.where(DeckyLifecycle.id == lifecycle_id)
|
||||
.values(**payload)
|
||||
)
|
||||
await session.commit()
|
||||
|
||||
async def get_lifecycle_by_ids(
|
||||
self, lifecycle_ids: list[str],
|
||||
) -> list[dict[str, Any]]:
|
||||
if not lifecycle_ids:
|
||||
return []
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
select(DeckyLifecycle)
|
||||
.where(DeckyLifecycle.id.in_(lifecycle_ids)) # type: ignore[attr-defined]
|
||||
.order_by(asc(DeckyLifecycle.started_at))
|
||||
)
|
||||
return [r.model_dump(mode="json") for r in result.scalars().all()]
|
||||
|
||||
async def find_open_lifecycle(
|
||||
self,
|
||||
decky_name: str,
|
||||
operation: str,
|
||||
host_uuid: Optional[str] = None,
|
||||
) -> Optional[dict[str, Any]]:
|
||||
stmt = (
|
||||
select(DeckyLifecycle)
|
||||
.where(DeckyLifecycle.decky_name == decky_name)
|
||||
.where(DeckyLifecycle.operation == operation)
|
||||
.where(DeckyLifecycle.status.in_(("pending", "running"))) # type: ignore[attr-defined]
|
||||
.order_by(DeckyLifecycle.started_at.desc()) # type: ignore[attr-defined]
|
||||
)
|
||||
if host_uuid is not None:
|
||||
stmt = stmt.where(DeckyLifecycle.host_uuid == host_uuid)
|
||||
async with self._session() as session:
|
||||
result = await session.execute(stmt)
|
||||
row = result.scalars().first()
|
||||
return row.model_dump(mode="json") if row else None
|
||||
|
||||
async def sweep_stale_lifecycle(
|
||||
self,
|
||||
older_than: datetime,
|
||||
reason: str,
|
||||
) -> int:
|
||||
now = datetime.now(timezone.utc)
|
||||
async with self._session() as session:
|
||||
result = await session.execute(
|
||||
update(DeckyLifecycle)
|
||||
.where(DeckyLifecycle.status.in_(("pending", "running"))) # type: ignore[attr-defined]
|
||||
.where(DeckyLifecycle.started_at < older_than)
|
||||
.values(
|
||||
status="failed",
|
||||
error=reason,
|
||||
updated_at=now,
|
||||
completed_at=now,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
return result.rowcount or 0
|
||||
Reference in New Issue
Block a user