feat(cloud-store): persist task lease history

This commit is contained in:
2026-07-12 17:15:09 +08:00
parent 52d2a86a4c
commit 0a9392b7ea
5 changed files with 155 additions and 7 deletions
+2 -1
View File
@@ -2,7 +2,7 @@ from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Protocol
from typing import TYPE_CHECKING, Any, Literal, Protocol
if TYPE_CHECKING:
from cloud.plugins import PluginManifest
@@ -27,6 +27,7 @@ class TaskAttemptRecord:
created_at: datetime
completed_at: datetime | None = None
failure_reason: str | None = None
terminal_result: dict[str, Any] | None = None
@dataclass(frozen=True)
+10 -2
View File
@@ -10,7 +10,7 @@ from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import TYPE_CHECKING, Literal, Protocol, runtime_checkable
from typing import TYPE_CHECKING, Any, Literal, Protocol, runtime_checkable
from uuid import uuid4
from core.models import utc_now
@@ -44,6 +44,12 @@ class ScheduledTask:
status: ScheduledTaskStatus = "queued"
assigned_device_id: str | None = None
assigned_host_id: str | None = None
attempt_count: int = 0
lease_id: str | None = None
lease_expires_at: datetime | None = None
terminal_result: dict[str, Any] | None = None
failure_reason: str | None = None
updated_at: datetime | None = None
created_at: datetime = field(default_factory=utc_now)
@@ -107,7 +113,9 @@ class TaskScheduler:
self.pool = pool
self.store = store
self.config = config
self._strategies = dict(strategies) if strategies is not None else dict(DEFAULT_STRATEGIES)
self._strategies = (
dict(strategies) if strategies is not None else dict(DEFAULT_STRATEGIES)
)
if config.default_assignment_strategy not in self._strategies:
raise UnknownAssignmentStrategyError(
f"unknown assignment strategy {config.default_assignment_strategy!r}; "
@@ -6,7 +6,7 @@ from datetime import datetime
from typing import Any
from sqlalchemy import Engine, delete, func, select
from sqlalchemy.orm import Session, sessionmaker
from sqlalchemy.orm import sessionmaker
from cloud.db_models import (
Base,
@@ -14,6 +14,7 @@ from cloud.db_models import (
PluginRow,
PooledDeviceRow,
ScheduledTaskRow,
TaskAttemptRow,
)
from core.models import utc_now
@@ -119,6 +120,18 @@ class SQLAlchemyCloudRepository:
status=task.status,
assigned_device_id=task.assigned_device_id,
assigned_host_id=task.assigned_host_id,
attempt_count=task.attempt_count,
lease_id=task.lease_id,
lease_expires_at=(
_iso(task.lease_expires_at) if task.lease_expires_at else None
),
failure_reason=task.failure_reason,
result_json=(
json.dumps(task.terminal_result, ensure_ascii=False)
if task.terminal_result is not None
else None
),
updated_at=_iso(task.updated_at) if task.updated_at else None,
created_at=_iso(task.created_at),
)
)
@@ -194,6 +207,15 @@ class SQLAlchemyCloudRepository:
row = session.get(PluginRow, name)
return _plugin_from_row(row) if row else None
def list_task_attempts(self, task_id: str) -> list[Any]:
with self._sessions() as session:
rows = session.scalars(
select(TaskAttemptRow)
.where(TaskAttemptRow.task_id == task_id)
.order_by(TaskAttemptRow.attempt)
).all()
return [_task_attempt_from_row(row) for row in rows]
def health_check(self) -> None:
with self._sessions() as session:
session.execute(select(1))
@@ -230,7 +252,7 @@ def _device_from_row(row: PooledDeviceRow) -> Any:
try:
tags = list(json.loads(row.capability_tags_json))
except (TypeError, ValueError):
except TypeError, ValueError:
tags = []
return PooledDevice(
device_id=row.device_id,
@@ -247,8 +269,9 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
try:
constraints_data = json.loads(row.constraints_json)
except (TypeError, ValueError):
except TypeError, ValueError:
constraints_data = {}
terminal_result = _parse_json_object(row.result_json)
return ScheduledTask(
id=row.id,
goal=row.goal,
@@ -260,10 +283,44 @@ def _task_from_row(row: ScheduledTaskRow) -> Any:
status=row.status,
assigned_device_id=row.assigned_device_id,
assigned_host_id=row.assigned_host_id,
attempt_count=row.attempt_count,
lease_id=row.lease_id,
lease_expires_at=_parse_dt(row.lease_expires_at),
terminal_result=terminal_result,
failure_reason=row.failure_reason,
updated_at=_parse_dt(row.updated_at),
created_at=_parse_dt(row.created_at) or utc_now(),
)
def _task_attempt_from_row(row: TaskAttemptRow) -> Any:
from cloud.repository import TaskAttemptRecord
return TaskAttemptRecord(
task_id=row.task_id,
attempt=row.attempt,
lease_id=row.lease_id,
host_id=row.host_id,
device_id=row.device_id,
status=row.status,
lease_expires_at=_parse_dt(row.lease_expires_at) or utc_now(),
created_at=_parse_dt(row.created_at) or utc_now(),
completed_at=_parse_dt(row.completed_at),
failure_reason=row.failure_reason,
terminal_result=_parse_json_object(row.result_json),
)
def _parse_json_object(value: str | None) -> dict[str, Any] | None:
if value is None:
return None
try:
parsed = json.loads(value)
except TypeError, ValueError:
return None
return parsed if isinstance(parsed, dict) else None
def _plugin_from_row(row: PluginRow) -> tuple[Any, bool]:
from cloud.plugins import PluginManifest