118 lines
3.9 KiB
Go
118 lines
3.9 KiB
Go
// Package notification implements the in-process notification pipeline
|
|
// described in `backend/PLAN.md` §5.7, `ARCHITECTURE.md` §12, and the
|
|
// catalog in `backend/README.md` §10. Producers (lobby, runtime) submit
|
|
// intents via Submit; the service persists each intent into
|
|
// `backend.notifications`, materialises one row per (recipient, channel)
|
|
// in `backend.notification_routes`, and attempts a synchronous best-effort
|
|
// dispatch. Failed routes are picked up by a background Worker that retries
|
|
// with exponential backoff and dead-letters past the configured maximum.
|
|
//
|
|
// Push routes are emitted via PushPublisher (the canonical
|
|
// `push.Service` over gRPC; the package also ships a
|
|
// NoopPushPublisher for tests). Email routes call
|
|
// mail.EnqueueTemplate, which feeds the durable mail outbox.
|
|
package notification
|
|
|
|
import (
|
|
"time"
|
|
|
|
"galaxy/backend/internal/config"
|
|
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// Status values stored in `notification_routes.status`. Mirrored by the
|
|
// CHECK constraint in migration 00001.
|
|
const (
|
|
RouteStatusPending = "pending"
|
|
RouteStatusRetrying = "retrying"
|
|
RouteStatusPublished = "published"
|
|
RouteStatusSkipped = "skipped"
|
|
RouteStatusDeadLettered = "dead_lettered"
|
|
)
|
|
|
|
// Channel values stored in `notification_routes.channel`. The catalog in
|
|
// `backend/README.md` §10 documents the per-kind set.
|
|
const (
|
|
ChannelPush = "push"
|
|
ChannelEmail = "email"
|
|
)
|
|
|
|
// Backoff parameters for the route worker. Mirrors the trade-off captured
|
|
// for the mail outbox in `backend/README.md`: exponential
|
|
// growth from a 10 second base, capped at 10 minutes, with ±25% jitter.
|
|
const (
|
|
backoffBase = 10 * time.Second
|
|
backoffFactor = 2.0
|
|
backoffMax = 10 * time.Minute
|
|
backoffJitter = 0.25
|
|
|
|
// claimBatchSize bounds the number of routes pulled out of Postgres
|
|
// per worker tick. Same logic as `mail.claimBatchSize`: each row is
|
|
// processed in its own short transaction so a slow channel does not
|
|
// block its peers.
|
|
claimBatchSize = 16
|
|
)
|
|
|
|
// Service is the notification entry point. It composes the persistence
|
|
// store, the push and mail dispatchers, the account resolver used for
|
|
// recipient email lookups, runtime configuration, and a structured
|
|
// logger.
|
|
type Service struct {
|
|
deps Deps
|
|
}
|
|
|
|
// NewService constructs a Service from deps. Nil Logger defaults to
|
|
// zap.NewNop; nil Now defaults to time.Now. Store, Mail, and Accounts
|
|
// must be non-nil — calling Service methods with either nil panics on
|
|
// first use, matching how the rest of `internal/*` signals missing
|
|
// wiring. A nil Push defaults to the no-op publisher used by tests
|
|
// that do not exercise the gRPC stream.
|
|
func NewService(deps Deps) *Service {
|
|
if deps.Now == nil {
|
|
deps.Now = time.Now
|
|
}
|
|
if deps.Logger == nil {
|
|
deps.Logger = zap.NewNop()
|
|
}
|
|
deps.Logger = deps.Logger.Named("notification")
|
|
if deps.Push == nil {
|
|
deps.Push = NewNoopPushPublisher(deps.Logger)
|
|
}
|
|
return &Service{deps: deps}
|
|
}
|
|
|
|
// Config returns the runtime notification configuration. Worker uses it
|
|
// to schedule the scan loop and bound retries.
|
|
func (s *Service) Config() config.NotificationConfig {
|
|
return s.deps.Config
|
|
}
|
|
|
|
// Logger returns the package-named structured logger. Worker and the
|
|
// admin handlers reuse it so scoped fields stay consistent.
|
|
func (s *Service) Logger() *zap.Logger {
|
|
return s.deps.Logger
|
|
}
|
|
|
|
// now returns the package-configured clock; the helper keeps the rest
|
|
// of the code free from `if s.deps.Now == nil` checks.
|
|
func (s *Service) now() time.Time {
|
|
if s.deps.Now == nil {
|
|
return time.Now()
|
|
}
|
|
return s.deps.Now()
|
|
}
|
|
|
|
// nowUTC returns the configured clock normalised to UTC, matching the
|
|
// convention used by `time.Time` columns elsewhere in `backend`.
|
|
func (s *Service) nowUTC() time.Time {
|
|
return s.now().UTC()
|
|
}
|
|
|
|
// adminEmail returns the configured admin recipient address with
|
|
// surrounding whitespace removed; the empty string indicates no admin
|
|
// recipient is configured.
|
|
func (s *Service) adminEmail() string {
|
|
return trimSpace(s.deps.Config.AdminEmail)
|
|
}
|