feat: backend service
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
// 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)
|
||||
}
|
||||
Reference in New Issue
Block a user