166 lines
5.8 KiB
Go
166 lines
5.8 KiB
Go
// Package healtheventspublisher provides the Redis-Streams-backed
|
|
// publisher for `runtime:health_events`. Every Publish call upserts the
|
|
// latest `health_snapshots` row before XADDing the event so consumers
|
|
// observing the snapshot store can never lag the event stream by more
|
|
// than the duration of one network call.
|
|
//
|
|
// The publisher is shared across `ports.HealthEventPublisher` callers:
|
|
// the start service emits `container_started`; the probe, inspect, and
|
|
// events-listener workers emit the rest. The publisher's surface is
|
|
// stable across all of them.
|
|
package healtheventspublisher
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
|
|
"galaxy/rtmanager/internal/domain/health"
|
|
"galaxy/rtmanager/internal/ports"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// emptyDetails is the canonical JSON payload installed when the caller
|
|
// supplies an empty Details slice. Matches the SQL DEFAULT for
|
|
// `health_snapshots.details`.
|
|
const emptyDetails = "{}"
|
|
|
|
// Wire field names used by the Redis Streams payload. Frozen by
|
|
// `rtmanager/api/runtime-health-asyncapi.yaml`; renaming any of them
|
|
// breaks consumers.
|
|
const (
|
|
fieldGameID = "game_id"
|
|
fieldContainerID = "container_id"
|
|
fieldEventType = "event_type"
|
|
fieldOccurredAtMS = "occurred_at_ms"
|
|
fieldDetails = "details"
|
|
)
|
|
|
|
// Config groups the dependencies and stream name required to construct
|
|
// a Publisher.
|
|
type Config struct {
|
|
// Client appends entries to the Redis Stream. Must be non-nil.
|
|
Client *redis.Client
|
|
|
|
// Snapshots upserts the latest health snapshot. Must be non-nil.
|
|
Snapshots ports.HealthSnapshotStore
|
|
|
|
// Stream stores the Redis Stream key events are published to (e.g.
|
|
// `runtime:health_events`). Must not be empty.
|
|
Stream string
|
|
}
|
|
|
|
// Publisher implements `ports.HealthEventPublisher` on top of a shared
|
|
// Redis client and the production `health_snapshots` store.
|
|
type Publisher struct {
|
|
client *redis.Client
|
|
snapshots ports.HealthSnapshotStore
|
|
stream string
|
|
}
|
|
|
|
// NewPublisher constructs one Publisher from cfg. Validation errors
|
|
// surface the missing collaborator verbatim.
|
|
func NewPublisher(cfg Config) (*Publisher, error) {
|
|
if cfg.Client == nil {
|
|
return nil, errors.New("new rtmanager health events publisher: nil redis client")
|
|
}
|
|
if cfg.Snapshots == nil {
|
|
return nil, errors.New("new rtmanager health events publisher: nil snapshot store")
|
|
}
|
|
if cfg.Stream == "" {
|
|
return nil, errors.New("new rtmanager health events publisher: stream must not be empty")
|
|
}
|
|
return &Publisher{
|
|
client: cfg.Client,
|
|
snapshots: cfg.Snapshots,
|
|
stream: cfg.Stream,
|
|
}, nil
|
|
}
|
|
|
|
// Publish upserts the matching health_snapshots row and then XADDs the
|
|
// envelope to the configured Redis Stream. Both side effects are
|
|
// required; the snapshot upsert runs first so a successful Publish
|
|
// always leaves the snapshot store at least as fresh as the stream.
|
|
func (publisher *Publisher) Publish(ctx context.Context, envelope ports.HealthEventEnvelope) error {
|
|
if publisher == nil || publisher.client == nil || publisher.snapshots == nil {
|
|
return errors.New("publish health event: nil publisher")
|
|
}
|
|
if ctx == nil {
|
|
return errors.New("publish health event: nil context")
|
|
}
|
|
if err := envelope.Validate(); err != nil {
|
|
return fmt.Errorf("publish health event: %w", err)
|
|
}
|
|
|
|
details := envelope.Details
|
|
if len(details) == 0 {
|
|
details = json.RawMessage(emptyDetails)
|
|
}
|
|
|
|
status, source := snapshotMappingFor(envelope.EventType)
|
|
snapshot := health.HealthSnapshot{
|
|
GameID: envelope.GameID,
|
|
ContainerID: envelope.ContainerID,
|
|
Status: status,
|
|
Source: source,
|
|
Details: details,
|
|
ObservedAt: envelope.OccurredAt.UTC(),
|
|
}
|
|
if err := publisher.snapshots.Upsert(ctx, snapshot); err != nil {
|
|
return fmt.Errorf("publish health event: upsert snapshot: %w", err)
|
|
}
|
|
|
|
occurredAtMS := envelope.OccurredAt.UTC().UnixMilli()
|
|
values := map[string]any{
|
|
fieldGameID: envelope.GameID,
|
|
fieldContainerID: envelope.ContainerID,
|
|
fieldEventType: string(envelope.EventType),
|
|
fieldOccurredAtMS: strconv.FormatInt(occurredAtMS, 10),
|
|
fieldDetails: string(details),
|
|
}
|
|
if err := publisher.client.XAdd(ctx, &redis.XAddArgs{
|
|
Stream: publisher.stream,
|
|
Values: values,
|
|
}).Err(); err != nil {
|
|
return fmt.Errorf("publish health event: xadd: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// snapshotMappingFor returns the SnapshotStatus and SnapshotSource that
|
|
// match eventType per `rtmanager/README.md §Health Monitoring`.
|
|
//
|
|
// `container_started` is observed when the start service successfully
|
|
// runs the container; the snapshot collapses it to `healthy`.
|
|
// `probe_recovered` collapses to `healthy` per
|
|
// `rtmanager/docs/domain-and-ports.md` §4: it does not have its own
|
|
// snapshot status; the next observation overwrites the prior
|
|
// `probe_failed` with `healthy`.
|
|
func snapshotMappingFor(eventType health.EventType) (health.SnapshotStatus, health.SnapshotSource) {
|
|
switch eventType {
|
|
case health.EventTypeContainerStarted:
|
|
return health.SnapshotStatusHealthy, health.SnapshotSourceDockerEvent
|
|
case health.EventTypeContainerExited:
|
|
return health.SnapshotStatusExited, health.SnapshotSourceDockerEvent
|
|
case health.EventTypeContainerOOM:
|
|
return health.SnapshotStatusOOM, health.SnapshotSourceDockerEvent
|
|
case health.EventTypeContainerDisappeared:
|
|
return health.SnapshotStatusContainerDisappeared, health.SnapshotSourceDockerEvent
|
|
case health.EventTypeInspectUnhealthy:
|
|
return health.SnapshotStatusInspectUnhealthy, health.SnapshotSourceInspect
|
|
case health.EventTypeProbeFailed:
|
|
return health.SnapshotStatusProbeFailed, health.SnapshotSourceProbe
|
|
case health.EventTypeProbeRecovered:
|
|
return health.SnapshotStatusHealthy, health.SnapshotSourceProbe
|
|
default:
|
|
return "", ""
|
|
}
|
|
}
|
|
|
|
// Compile-time assertion: Publisher implements
|
|
// ports.HealthEventPublisher.
|
|
var _ ports.HealthEventPublisher = (*Publisher)(nil)
|