Files
next-tjwater-server/app/services/task_alerts.py
T

125 lines
5.0 KiB
Python

"""Monotonic, idempotent state machine for task-produced alerts."""
from __future__ import annotations
from datetime import datetime, timezone
from app.domain.schemas.task_alert import TaskAlertEvent, TaskAlertState
from app.domain.domain_errors import require
from app.services.content_digest import digest
class TaskAlertService:
def __init__(
self,
repository,
context,
*,
clock=None,
internal_producer_id: str | None = None,
):
self.repo = repository
self.context = context
self.clock = clock or (lambda: datetime.now(timezone.utc))
self.internal_producer_id = internal_producer_id
async def push(self, command: TaskAlertEvent) -> TaskAlertState:
if self.internal_producer_id is None:
require(
self.context is not None and self.context.project_role == "member",
"PERMISSION_DENIED",
"Alert events require project write permission",
status=403,
)
require(
command.producer_id == f"principal:{self.context.user_id}",
"PERMISSION_DENIED",
"Alert producer must match the authenticated project principal",
status=403,
)
else:
require(
command.producer_id == self.internal_producer_id,
"PERMISSION_DENIED",
"Internal alert producer identity does not match",
status=403,
)
event = command.model_dump(mode="json")
async with self.repo.transaction():
await self.repo.lock_alert(command.alert_id)
prior_event = await self.repo.get_event(
command.producer_id, str(command.event_id)
)
if prior_event is not None:
prior_command = {
key: value for key, value in prior_event.items()
if key != "resulting_state"
}
require(
digest(prior_command) == digest(event),
"IDEMPOTENCY_CONFLICT",
"event_id was used with a different alert event",
status=409,
)
return TaskAlertState.model_validate(prior_event["resulting_state"])
current = await self.repo.get_alert(command.alert_id)
state = self._transition(command, current)
state_document = state.model_dump(mode="json")
await self.repo.save_event(event, state_document)
await self.repo.save_alert(state_document)
return state
def _transition(
self, command: TaskAlertEvent, current: tuple[int, dict] | None
) -> TaskAlertState:
now = self.clock()
require(now.tzinfo is not None, "SCHEMA_INVALID", "Alert clock must be timezone-aware")
if current is None:
require(command.event_type == "raised", "INVALID_TRANSITION",
"An alert must be raised before it can change state", status=409)
revision = 1
status = "active"
else:
revision, document = current
require(document.get("producer_id") == command.producer_id,
"PERMISSION_DENIED", "Only the originating producer can update an alert", status=403)
require(document.get("source") == command.source.model_dump(mode="json"),
"SCOPE_MISMATCH", "Alert source cannot change", status=409)
require(
all(
document.get(field) == event_value
for field, event_value in {
"condition_id": command.condition_id,
"title": command.title,
"description": command.description,
"occurred_at": command.model_dump(mode="json")["occurred_at"],
"severity": command.severity,
}.items()
),
"IDEMPOTENCY_CONFLICT",
"Alert facts cannot change after the alert is raised",
status=409,
)
allowed = {
("active", "acknowledged"): "acknowledged",
("active", "resolved"): "resolved",
("acknowledged", "resolved"): "resolved",
}
status = allowed.get((document["status"], command.event_type))
require(status is not None, "INVALID_TRANSITION",
"Alert state transition is not permitted", status=409)
revision += 1
return TaskAlertState(
alert_id=command.alert_id,
revision=revision,
producer_id=command.producer_id,
condition_id=command.condition_id,
source=command.source,
title=command.title,
description=command.description,
occurred_at=command.occurred_at,
severity=command.severity,
status=status,
updated_at=now,
)