# [DEF:TranslationEventLog:Module] # @COMPLEXITY: 5 # @SEMANTICS: translate, events, audit, logging # @PURPOSE: Structured event logging for translation operations with terminal event invariant enforcement. # @LAYER: Domain # @RELATION: DEPENDS_ON -> [TranslationEvent] # @RELATION: DEPENDS_ON -> [MetricSnapshot] # @PRE: Database session is open and valid. # @POST: Events are persisted immutably; terminal events enforce exactly-one invariant per run. # @SIDE_EFFECT: Writes TranslationEvent rows; prunes expired events with MetricSnapshot before deletion. # @DATA_CONTRACT: Input[run_id:Optional[str], job_id:str, event_type:str, payload:dict] -> Output[TranslationEvent] # @INVARIANT: Exactly one run_started + exactly one terminal event per non-null run_id. # @RATIONALE: Immutable event log with nullable run_id allows job-level events (no run context) and run-level events. # @REJECTED: stdout-only logging — lacks structured payload integrity; cannot enforce terminal-event invariant. # @REJECTED: Separate event table per entity — single TranslationEvent table with nullable run_id is simpler for audit. from typing import Any, Dict, List, Optional from sqlalchemy.orm import Session from datetime import datetime, timezone, timedelta import uuid from ...core.logger import logger, belief_scope from ...models.translate import TranslationEvent, MetricSnapshot # Terminal events per run — exactly one of these must exist for a completed run. TERMINAL_EVENT_TYPES = {"RUN_COMPLETED", "RUN_FAILED", "RUN_CANCELLED"} # All valid event types for validation. VALID_EVENT_TYPES = TERMINAL_EVENT_TYPES | { "JOB_CREATED", "JOB_UPDATED", "JOB_DELETED", "RUN_STARTED", "RUN_RETRYING", "RUN_RETRY_INSERT", "BATCH_STARTED", "BATCH_COMPLETED", "BATCH_FAILED", "BATCH_RETRYING", "TRANSLATION_PHASE_STARTED", "TRANSLATION_PHASE_COMPLETED", "INSERT_PHASE_STARTED", "INSERT_PHASE_COMPLETED", "PREVIEW_CREATED", "PREVIEW_ACCEPTED", "PREVIEW_DISCARDED", "SCHEDULE_CREATED", "SCHEDULE_UPDATED", "SCHEDULE_DELETED", "METRICS_SNAPSHOT_CREATED", } DEFAULT_RETENTION_DAYS = 90 # [DEF:TranslationEventLog:Class] # @COMPLEXITY: 5 # @PURPOSE: Structured event logging for translation operations with terminal event invariant enforcement. # @PRE: Database session is available. # @POST: Events are written immutably; terminal events enforced per run. # @SIDE_EFFECT: Writes TranslationEvent rows; prunes expired events. # @INVARIANT: Exactly one run_started + exactly one terminal event per non-null run_id. class TranslationEventLog: def __init__(self, db: Session): self.db = db # [DEF:log_event:Function] # @COMPLEXITY: 4 # @PURPOSE: Write an immutable event. Enforces terminal event invariant for non-null run_id. # @PRE: event_type must be a known type. If run_id is not None, enforce terminal invariant. # @POST: TranslationEvent row is created. # @SIDE_EFFECT: DB write. def log_event( self, job_id: str, event_type: str, payload: Optional[Dict[str, Any]] = None, run_id: Optional[str] = None, created_by: Optional[str] = None, ) -> TranslationEvent: with belief_scope("TranslationEventLog.log_event"): if event_type not in VALID_EVENT_TYPES: raise ValueError( f"Invalid event_type '{event_type}'. " f"Must be one of: {', '.join(sorted(VALID_EVENT_TYPES))}" ) # Enforce terminal event invariant for non-null run_id if run_id is not None and event_type in TERMINAL_EVENT_TYPES: existing_terminal = ( self.db.query(TranslationEvent.id) .filter( TranslationEvent.run_id == run_id, TranslationEvent.event_type.in_(TERMINAL_EVENT_TYPES), ) .first() ) if existing_terminal: raise ValueError( f"Run '{run_id}' already has a terminal event " f"({existing_terminal[0]}). Cannot add another terminal event." ) # Enforce run_started invariant — must exist before other run events if run_id is not None and event_type not in {"RUN_STARTED"} | TERMINAL_EVENT_TYPES: has_started = ( self.db.query(TranslationEvent.id) .filter( TranslationEvent.run_id == run_id, TranslationEvent.event_type == "RUN_STARTED", ) .first() ) if not has_started: raise ValueError( f"Cannot log '{event_type}' for run '{run_id}': " f"RUN_STARTED event must precede other run events." ) event = TranslationEvent( id=str(uuid.uuid4()), job_id=job_id, run_id=run_id, event_type=event_type, event_data=payload or {}, created_by=created_by, created_at=datetime.now(timezone.utc), ) self.db.add(event) self.db.flush() logger.reason(f"Event logged: {event_type}", { "event_id": event.id, "job_id": job_id, "run_id": run_id, "event_type": event_type, }) return event # [/DEF:log_event:Function] # [DEF:query_events:Function] # @PURPOSE: Query events with optional filters. # @PRE: None. # @POST: Returns list of TranslationEvent dicts matching filters. def query_events( self, job_id: Optional[str] = None, run_id: Optional[str] = None, event_type: Optional[str] = None, limit: int = 100, offset: int = 0, ) -> List[Dict[str, Any]]: with belief_scope("TranslationEventLog.query_events"): query = self.db.query(TranslationEvent) if job_id: query = query.filter(TranslationEvent.job_id == job_id) if run_id: query = query.filter(TranslationEvent.run_id == run_id) if event_type: query = query.filter(TranslationEvent.event_type == event_type) events = ( query.order_by(TranslationEvent.created_at.desc()) .offset(offset) .limit(limit) .all() ) return [ { "id": e.id, "job_id": e.job_id, "run_id": e.run_id, "event_type": e.event_type, "event_data": e.event_data or {}, "created_by": e.created_by, "created_at": e.created_at.isoformat() if e.created_at else None, } for e in events ] # [/DEF:query_events:Function] # [DEF:prune_expired:Function] # @PURPOSE: Delete events older than retention_days. Persists MetricSnapshot before pruning. # @PRE: None. # @POST: Expired events are deleted; MetricSnapshot is created before deletion. # @SIDE_EFFECT: Creates MetricSnapshot row; deletes TranslationEvent rows. def prune_expired( self, retention_days: int = DEFAULT_RETENTION_DAYS, batch_size: int = 1000, ) -> Dict[str, Any]: with belief_scope("TranslationEventLog.prune_expired"): cutoff = datetime.now(timezone.utc) - timedelta(days=retention_days) logger.reason("Pruning expired events", { "cutoff": cutoff.isoformat(), "retention_days": retention_days, }) # Find events to prune expired_query = self.db.query(TranslationEvent).filter( TranslationEvent.created_at < cutoff ) total_expired = expired_query.count() if total_expired == 0: logger.reflect("No expired events to prune", {}) return {"pruned": 0, "snapshot_id": None} # Create MetricSnapshot before pruning snapshot = MetricSnapshot( id=str(uuid.uuid4()), job_id="_prune_aggregate_", key_hash=f"prune_{cutoff.timestamp():.0f}", covers_events_before=cutoff, total_records=total_expired, snapshot_date=datetime.now(timezone.utc), ) self.db.add(snapshot) self.db.flush() snapshot_id = snapshot.id # Delete in batches pruned = 0 while True: batch_ids = ( self.db.query(TranslationEvent.id) .filter(TranslationEvent.created_at < cutoff) .limit(batch_size) .all() ) if not batch_ids: break ids = [row[0] for row in batch_ids] self.db.query(TranslationEvent).filter( TranslationEvent.id.in_(ids) ).delete(synchronize_session=False) self.db.flush() pruned += len(ids) logger.reason("Pruned batch", {"batch_size": len(ids), "total_pruned": pruned}) self.db.commit() logger.reflect("Pruning complete", { "pruned": pruned, "snapshot_id": snapshot_id, }) return {"pruned": pruned, "snapshot_id": snapshot_id} # [/DEF:prune_expired:Function] # [DEF:get_run_event_summary:Function] # @PURPOSE: Get a summary of events for a run, including invariant check. # @PRE: run_id is not None. # @POST: Returns dict with event list and invariant validity. def get_run_event_summary(self, run_id: str) -> Dict[str, Any]: with belief_scope("TranslationEventLog.get_run_event_summary"): events = self.query_events(run_id=run_id) has_started = any(e["event_type"] == "RUN_STARTED" for e in events) terminal_events = [e for e in events if e["event_type"] in TERMINAL_EVENT_TYPES] return { "run_id": run_id, "event_count": len(events), "has_run_started": has_started, "terminal_event_count": len(terminal_events), "terminal_events": terminal_events, "invariant_valid": has_started and len(terminal_events) <= 1, "events": events, } # [/DEF:get_run_event_summary:Function] # [/DEF:TranslationEventLog:Class] # [/DEF:TranslationEventLog:Module]