package incident import ( "context" "crypto/rand" "encoding/hex" "errors" "fmt" "strings" "time" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgconn" "github.com/jackc/pgx/v5/pgxpool" ) type Repository struct{ Pool *pgxpool.Pool } type Store interface { List(context.Context, int, Status) ([]Incident, error) Get(context.Context, string) (Incident, error) UpsertCandidate(context.Context, Candidate) (Incident, bool, error) AssociateAlert(context.Context, string, string, string, float64, string) (Association, bool, error) DisassociateAlert(context.Context, string, string) error UpdateStatus(context.Context, string, Status, int64, time.Time) (Incident, error) UpdateOwner(context.Context, string, string, int64) (Incident, error) AddNote(context.Context, string, string, string) (Note, error) ListNotes(context.Context, string, int) ([]Note, error) } func NewRepository(pool *pgxpool.Pool) (*Repository, error) { if pool == nil { return nil, ErrUnavailable } return &Repository{Pool: pool}, nil } func (r *Repository) UpsertCandidate(ctx context.Context, candidate Candidate) (Incident, bool, error) { if r == nil || r.Pool == nil { return Incident{}, false, ErrUnavailable } if err := candidate.Validate(); err != nil { return Incident{}, false, err } tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return Incident{}, false, fmt.Errorf("begin incident correlation: %w", err) } defer func() { _ = tx.Rollback(ctx) }() if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext($1))`, candidate.CorrelationKey); err != nil { return Incident{}, false, fmt.Errorf("lock incident correlation: %w", err) } var incident Incident err = scanIncident(tx.QueryRow(ctx, incidentSelect+` WHERE correlation_key=$1 AND status <> 'resolved' FOR UPDATE`, candidate.CorrelationKey), &incident) created := false if errors.Is(err, pgx.ErrNoRows) { created = true err = scanIncident(tx.QueryRow(ctx, `INSERT INTO incidents (id,correlation_key,title,summary,severity,status,started_at,correlation_method,confidence) VALUES ($1,$2,$3,$4,$5,'open',$6,$7,$8) RETURNING `+incidentColumns, newID(), candidate.CorrelationKey, candidate.Title, candidate.Summary, candidate.Severity, candidate.StartedAt.UTC(), candidate.CorrelationMethod, candidate.Confidence), &incident) } if err != nil { return Incident{}, false, mapError("find or create incident", err) } if !created { err = scanIncident(tx.QueryRow(ctx, `UPDATE incidents SET title=$1,summary=$2,severity=$3,started_at=LEAST(started_at,$4),correlation_method=$5,confidence=$6,revision=revision+1,updated_at=now() WHERE id=$7 RETURNING `+incidentColumns, candidate.Title, candidate.Summary, candidate.Severity, candidate.StartedAt.UTC(), candidate.CorrelationMethod, candidate.Confidence, incident.ID), &incident) if err != nil { return Incident{}, false, fmt.Errorf("update incident correlation: %w", err) } } for _, signal := range candidate.Signals { if _, err := tx.Exec(ctx, `INSERT INTO incident_alerts (incident_id,alert_id,rationale,confidence,correlation_method,is_manual,added_by) VALUES ($1,$2,$3,$4,$5,false,'') ON CONFLICT (incident_id,alert_id) DO UPDATE SET rationale=EXCLUDED.rationale,confidence=EXCLUDED.confidence,correlation_method=EXCLUDED.correlation_method WHERE incident_alerts.is_manual=false`, incident.ID, signal.AlertID, signalRationale(signal, candidate.CorrelationMethod), methodConfidence(candidate.CorrelationMethod), candidate.CorrelationMethod); err != nil { return Incident{}, false, mapError("associate correlated alert", err) } if _, err := tx.Exec(ctx, `INSERT INTO incident_entities (incident_id,entity_id,rationale,confidence) VALUES ($1,$2,$3,$4) ON CONFLICT (incident_id,entity_id) DO NOTHING`, incident.ID, signal.EntityID, signalRationale(signal, candidate.CorrelationMethod), methodConfidence(candidate.CorrelationMethod)); err != nil { return Incident{}, false, mapError("associate incident entity", err) } } if err := tx.Commit(ctx); err != nil { return Incident{}, false, fmt.Errorf("commit incident correlation: %w", err) } item, err := r.Get(ctx, incident.ID) return item, created, err } func (r *Repository) List(ctx context.Context, limit int, status Status) ([]Incident, error) { if r == nil || r.Pool == nil { return nil, ErrUnavailable } if limit < 1 || limit > 100 { return nil, fmt.Errorf("%w: incident limit is invalid", ErrInvalid) } if status != "" && !validStatus(status) { return nil, fmt.Errorf("%w: incident status is invalid", ErrInvalid) } rows, err := r.Pool.Query(ctx, incidentSelect+` WHERE ($2='' OR status=$2) ORDER BY CASE status WHEN 'open' THEN 1 WHEN 'acknowledged' THEN 2 ELSE 3 END,severity DESC,updated_at DESC,id ASC LIMIT $1`, limit, status) if err != nil { return nil, fmt.Errorf("list incidents: %w", err) } defer rows.Close() items := make([]Incident, 0, limit) for rows.Next() { var item Incident if err := scanIncident(rows, &item); err != nil { return nil, err } items = append(items, item) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("list incident rows: %w", err) } return items, nil } func (r *Repository) Get(ctx context.Context, id string) (Incident, error) { if r == nil || r.Pool == nil { return Incident{}, ErrUnavailable } if strings.TrimSpace(id) == "" { return Incident{}, ErrNotFound } var item Incident if err := scanIncident(r.Pool.QueryRow(ctx, incidentSelect+` WHERE id=$1`, id), &item); err != nil { if errors.Is(err, pgx.ErrNoRows) { return Incident{}, ErrNotFound } return Incident{}, fmt.Errorf("get incident: %w", err) } alerts, err := r.Pool.Query(ctx, `SELECT alert_id::text,rationale,confidence,correlation_method,is_manual,added_by,created_at FROM incident_alerts WHERE incident_id=$1 ORDER BY created_at ASC,alert_id ASC`, id) if err != nil { return Incident{}, fmt.Errorf("list incident alerts: %w", err) } for alerts.Next() { var association Association if err := alerts.Scan(&association.AlertID, &association.Rationale, &association.Confidence, &association.CorrelationMethod, &association.Manual, &association.AddedBy, &association.CreatedAt); err != nil { alerts.Close() return Incident{}, fmt.Errorf("scan incident alert: %w", err) } item.Alerts = append(item.Alerts, association) } if err := alerts.Err(); err != nil { alerts.Close() return Incident{}, err } alerts.Close() entities, err := r.Pool.Query(ctx, `SELECT entity_id::text,rationale,confidence,created_at FROM incident_entities WHERE incident_id=$1 ORDER BY created_at ASC,entity_id ASC`, id) if err != nil { return Incident{}, fmt.Errorf("list incident entities: %w", err) } defer entities.Close() for entities.Next() { var link EntityLink if err := entities.Scan(&link.EntityID, &link.Rationale, &link.Confidence, &link.CreatedAt); err != nil { return Incident{}, fmt.Errorf("scan incident entity: %w", err) } item.Entities = append(item.Entities, link) } if err := entities.Err(); err != nil { return Incident{}, err } return item, nil } func (r *Repository) AssociateAlert(ctx context.Context, incidentID, alertID, rationale string, confidence float64, actor string) (Association, bool, error) { if r == nil || r.Pool == nil { return Association{}, false, ErrUnavailable } if incidentID == "" || alertID == "" || len(rationale) < 1 || len(rationale) > MaxRationale || confidence < 0 || confidence > 1 || len(actor) > 160 { return Association{}, false, ErrInvalid } tx, err := r.Pool.BeginTx(ctx, pgx.TxOptions{}) if err != nil { return Association{}, false, fmt.Errorf("begin manual incident association: %w", err) } defer func() { _ = tx.Rollback(ctx) }() var existing Association err = tx.QueryRow(ctx, `SELECT alert_id::text,rationale,confidence,correlation_method,is_manual,added_by,created_at FROM incident_alerts WHERE incident_id=$1 AND alert_id=$2 FOR UPDATE`, incidentID, alertID).Scan(&existing.AlertID, &existing.Rationale, &existing.Confidence, &existing.CorrelationMethod, &existing.Manual, &existing.AddedBy, &existing.CreatedAt) if err == nil && existing.Manual && existing.Rationale == rationale && existing.AddedBy == actor { if err := tx.Commit(ctx); err != nil { return Association{}, false, err } return existing, true, nil } if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Association{}, false, err } var association Association err = tx.QueryRow(ctx, `INSERT INTO incident_alerts (incident_id,alert_id,rationale,confidence,correlation_method,is_manual,added_by) VALUES ($1,$2,$3,$4,'manual',true,$5) ON CONFLICT (incident_id,alert_id) DO UPDATE SET rationale=EXCLUDED.rationale,confidence=EXCLUDED.confidence,correlation_method='manual',is_manual=true,added_by=EXCLUDED.added_by RETURNING alert_id::text,rationale,confidence,correlation_method,is_manual,added_by,created_at`, incidentID, alertID, rationale, confidence, actor).Scan(&association.AlertID, &association.Rationale, &association.Confidence, &association.CorrelationMethod, &association.Manual, &association.AddedBy, &association.CreatedAt) if err != nil { return Association{}, false, mapError("associate incident alert", err) } if _, err := tx.Exec(ctx, `UPDATE incidents SET revision=revision+1,updated_at=now() WHERE id=$1`, incidentID); err != nil { return Association{}, false, err } if err := tx.Commit(ctx); err != nil { return Association{}, false, fmt.Errorf("commit manual incident association: %w", err) } return association, false, nil } func (r *Repository) DisassociateAlert(ctx context.Context, incidentID, alertID string) error { if r == nil || r.Pool == nil { return ErrUnavailable } command, err := r.Pool.Exec(ctx, `DELETE FROM incident_alerts WHERE incident_id=$1 AND alert_id=$2 AND is_manual=true`, incidentID, alertID) if err != nil { return mapError("remove manual incident alert", err) } if command.RowsAffected() == 0 { return ErrNotFound } _, err = r.Pool.Exec(ctx, `UPDATE incidents SET revision=revision+1,updated_at=now() WHERE id=$1`, incidentID) return err } func (r *Repository) UpdateStatus(ctx context.Context, id string, status Status, expectedRevision int64, at time.Time) (Incident, error) { if r == nil || r.Pool == nil { return Incident{}, ErrUnavailable } if !validStatus(status) || expectedRevision < 1 || at.IsZero() { return Incident{}, ErrInvalid } var item Incident resolved := any(nil) if status == StatusResolved { resolved = at.UTC() } err := scanIncident(r.Pool.QueryRow(ctx, `UPDATE incidents SET status=$1,resolved_at=$2,revision=revision+1,updated_at=$3 WHERE id=$4 AND revision=$5 RETURNING `+incidentColumns, status, resolved, at.UTC(), id, expectedRevision), &item) if errors.Is(err, pgx.ErrNoRows) { if _, getErr := r.Get(ctx, id); errors.Is(getErr, ErrNotFound) { return Incident{}, ErrNotFound } return Incident{}, ErrConflict } if err != nil { return Incident{}, mapError("update incident status", err) } return r.Get(ctx, item.ID) } const incidentColumns = `id::text,correlation_key,title,summary,severity,status,started_at,resolved_at,COALESCE(owner_user_id::text,''),correlation_method,confidence,revision,created_at,updated_at` const incidentSelect = `SELECT ` + incidentColumns + ` FROM incidents` func scanIncident(row interface{ Scan(...any) error }, item *Incident) error { return row.Scan(&item.ID, &item.CorrelationKey, &item.Title, &item.Summary, &item.Severity, &item.Status, &item.StartedAt, &item.ResolvedAt, &item.OwnerUserID, &item.CorrelationMethod, &item.Confidence, &item.Revision, &item.CreatedAt, &item.UpdatedAt) } func signalRationale(signal Signal, method string) string { if signal.DependencyOutage { return "Primary dependency outage is the common causal signal." } if signal.ParentEntityID != "" { return "Alert shares a known parent dependency." } return "Alert shares a deterministic " + method + " key." } func mapError(operation string, err error) error { var pgErr *pgconn.PgError if errors.As(err, &pgErr) { switch pgErr.Code { case "23503", "23505": return fmt.Errorf("%w: %s", ErrConflict, operation) case "23514", "22P02": return fmt.Errorf("%w: %s", ErrInvalid, operation) } } return fmt.Errorf("%s: %w", operation, err) } func newID() string { bytes := make([]byte, 16) if _, err := rand.Read(bytes); err != nil { return "00000000-0000-4000-8000-000000000000" } bytes[6] = (bytes[6] & 15) | 64 bytes[8] = (bytes[8] & 63) | 128 value := hex.EncodeToString(bytes) return value[:8] + "-" + value[8:12] + "-" + value[12:16] + "-" + value[16:20] + "-" + value[20:] }