128 lines
5.2 KiB
Python
128 lines
5.2 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)
|
|
append_changed_event = getattr(self.repo, "append_changed_event", None)
|
|
if append_changed_event is not None:
|
|
await append_changed_event(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,
|
|
)
|