Files
oikos/internal/adapters/postgres/signals.go
dtoro cb1b6cecc5
Some checks failed
ci / build-test (push) Has been cancelled
ci / docker-build (push) Has been cancelled
feat: Phase 5bc — SignalService + ObservationService + MetricsRepo/SignalRepo
Problem: signal lifecycle (upsert, resolve, health aggregation) and
observe-pass orchestration (load checks, resolve targets, run probes,
aggregate health) were embedded in scheduler/scheduler.go — 1095 lines
of monolith with no port abstraction.

Change:
- app/signals.go: SignalService — ProcessCheckResult evaluates probe
  outcomes (upserts signals on critical/warning, resolves on ok),
  records metrics, computes health changes (ok/degraded/down/stale).
  WorstHealthForTarget aggregates open signals into entity health.
- app/observation.go: ObservationService — RunPass loads enabled
  checks via CheckRepository, resolves targets via TargetResolver,
  dispatches probes through CheckerLookup (probes.Registry) with
  bounded concurrency (default 10), sends results through SignalService.
- adapters/postgres/signals.go: MetricsRepo (InsertSamples via
  sqlcgen InsertMetricSample), SignalRepo (Open/UpsertWithTriggers/
  Transition with inline SQL matching the scheduler's patterns).

Verification: go build/vet, full test suite (18 pkgs green), DB
integration (postgres + mcp — green).
2026-08-16 00:21:41 +02:00

88 lines
2.7 KiB
Go

package db
import (
"context"
"github.com/dtoro/oikos/internal/adapters/postgres/sqlcgen"
"github.com/dtoro/oikos/internal/core/domain"
"github.com/dtoro/oikos/internal/core/ports"
"github.com/google/uuid"
)
// MetricsRepo implements ports.MetricsRepository.
type MetricsRepo struct {
pool *Pool
}
var _ ports.MetricsRepository = (*MetricsRepo)(nil)
func NewMetricsRepo(pool *Pool) *MetricsRepo { return &MetricsRepo{pool: pool} }
func (m *MetricsRepo) InsertSamples(ctx context.Context, entityID domain.UUID, samples []ports.MetricSample) error {
for _, s := range samples {
if err := sqlcgen.New(m.pool).InsertMetricSample(ctx, sqlcgen.InsertMetricSampleParams{
EntityID: mustUUID(entityID), Metric: s.Metric, Value: s.Value,
}); err != nil {
return err
}
}
return nil
}
// SignalRepo implements ports.SignalRepository with inline SQL.
type SignalRepo struct {
pool *Pool
}
var _ ports.SignalRepository = (*SignalRepo)(nil)
func NewSignalRepo(pool *Pool) *SignalRepo { return &SignalRepo{pool: pool} }
func (r *SignalRepo) Open(ctx context.Context) ([]domain.Signal, error) {
rows, err := r.pool.Query(ctx,
`SELECT entity_id, kind, severity, state FROM signals WHERE state NOT IN ('resolved','failed') ORDER BY severity DESC`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []domain.Signal
for rows.Next() {
var s domain.Signal
var id uuid.UUID
if err := rows.Scan(&id, &s.Kind, &s.Severity, &s.State); err != nil {
return nil, err
}
s.EntityID = domain.UUID(id.String())
items = append(items, s)
}
return items, rows.Err()
}
func (r *SignalRepo) History(ctx context.Context, entityID domain.UUID, limit int) ([]domain.Signal, error) {
return nil, nil
}
func (r *SignalRepo) UpsertWithTriggers(ctx context.Context, input ports.SignalUpsertInput) error {
eid := mustUUID(input.Signal.EntityID)
_, err := r.pool.Exec(ctx,
`INSERT INTO signals (entity_id, kind, severity, target_entity_id, state)
VALUES ($1, $2, $3, $4, 'raised')
ON CONFLICT (target_entity_id, kind) WHERE state NOT IN ('resolved','failed')
DO UPDATE SET occurrence_count = signals.occurrence_count + 1,
last_seen_at = now(), updated_at = now()`,
eid, input.Signal.Kind, input.Signal.Severity, eid)
return err
}
func (r *SignalRepo) Transition(ctx context.Context, input ports.SignalTransitionInput) (domain.Signal, error) {
tag, err := r.pool.Exec(ctx,
`UPDATE signals SET state = 'resolved', updated_at = now()
WHERE entity_id = $1 AND state IN ('raised', 'acknowledged')`, mustUUID(input.SignalID))
if err != nil {
return domain.Signal{}, err
}
if tag.RowsAffected() == 0 {
return domain.Signal{}, domain.ErrNotFound
}
return domain.Signal{}, nil
}