feat: notification service

This commit is contained in:
Ilia Denisov
2026-04-22 08:49:45 +02:00
committed by GitHub
parent 5b7593e6f6
commit 32dc29359a
135 changed files with 21828 additions and 130 deletions
+229
View File
@@ -0,0 +1,229 @@
package app
import (
"context"
"errors"
"fmt"
"log/slog"
redisadapter "galaxy/notification/internal/adapters/redis"
"galaxy/notification/internal/adapters/redisstate"
userserviceadapter "galaxy/notification/internal/adapters/userservice"
"galaxy/notification/internal/api/internalhttp"
"galaxy/notification/internal/config"
"galaxy/notification/internal/service/acceptintent"
"galaxy/notification/internal/telemetry"
"galaxy/notification/internal/worker"
"github.com/redis/go-redis/v9"
)
// Runtime owns the runnable Notification Service process plus the cleanup
// functions that release runtime resources after shutdown.
type Runtime struct {
cfg config.Config
app *App
probeServer *internalhttp.Server
telemetry *telemetry.Runtime
intentConsumer *worker.IntentConsumer
pushPublisher *worker.PushPublisher
emailPublisher *worker.EmailPublisher
cleanupFns []func() error
}
// NewRuntime constructs the runnable Notification Service process from cfg.
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
if ctx == nil {
return nil, fmt.Errorf("new notification runtime: nil context")
}
if err := cfg.Validate(); err != nil {
return nil, fmt.Errorf("new notification runtime: %w", err)
}
if logger == nil {
logger = slog.Default()
}
runtime := &Runtime{
cfg: cfg,
}
cleanupOnError := func(err error) (*Runtime, error) {
if cleanupErr := runtime.Close(); cleanupErr != nil {
return nil, fmt.Errorf("%w; cleanup: %w", err, cleanupErr)
}
return nil, err
}
telemetryRuntime, err := telemetry.NewProcess(ctx, telemetry.ProcessConfig{
ServiceName: cfg.Telemetry.ServiceName,
TracesExporter: cfg.Telemetry.TracesExporter,
MetricsExporter: cfg.Telemetry.MetricsExporter,
TracesProtocol: cfg.Telemetry.TracesProtocol,
MetricsProtocol: cfg.Telemetry.MetricsProtocol,
StdoutTracesEnabled: cfg.Telemetry.StdoutTracesEnabled,
StdoutMetricsEnabled: cfg.Telemetry.StdoutMetricsEnabled,
}, logger.With("component", "telemetry"))
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: telemetry: %w", err))
}
runtime.telemetry = telemetryRuntime
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
defer cancel()
return telemetryRuntime.Shutdown(shutdownCtx)
})
redisClient := redisadapter.NewClient(cfg.Redis)
if err := redisadapter.InstrumentClient(redisClient, telemetryRuntime); err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
err := redisClient.Close()
if errors.Is(err, redis.ErrClosed) {
return nil
}
return err
})
if err := redisadapter.Ping(ctx, cfg.Redis, redisClient); err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: %w", err))
}
acceptanceStore, err := redisstate.NewAcceptanceStore(redisClient, redisstate.AcceptanceConfig{
RecordTTL: cfg.Retry.RecordTTL,
DeadLetterTTL: cfg.Retry.DeadLetterTTL,
IdempotencyTTL: cfg.Retry.IdempotencyTTL,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: acceptance store: %w", err))
}
malformedIntentStore, err := redisstate.NewMalformedIntentStore(redisClient, cfg.Retry.DeadLetterTTL)
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: malformed intent store: %w", err))
}
streamOffsetStore, err := redisstate.NewStreamOffsetStore(redisClient)
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: stream offset store: %w", err))
}
intentStreamLagReader, err := redisstate.NewIntentStreamLagReader(streamOffsetStore, cfg.Streams.Intents)
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: intent stream lag reader: %w", err))
}
telemetryRuntime.SetRouteScheduleSnapshotReader(acceptanceStore)
telemetryRuntime.SetIntentStreamLagSnapshotReader(intentStreamLagReader)
userDirectory, err := userserviceadapter.NewClient(userserviceadapter.Config{
BaseURL: cfg.UserService.BaseURL,
RequestTimeout: cfg.UserService.Timeout,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: user service client: %w", err))
}
runtime.cleanupFns = append(runtime.cleanupFns, userDirectory.Close)
acceptIntentService, err := acceptintent.New(acceptintent.Config{
Store: acceptanceStore,
UserDirectory: userDirectory,
Clock: nil,
Logger: logger,
Telemetry: telemetryRuntime,
PushMaxAttempts: cfg.Retry.PushMaxAttempts,
EmailMaxAttempts: cfg.Retry.EmailMaxAttempts,
IdempotencyTTL: cfg.Retry.IdempotencyTTL,
AdminRouting: cfg.AdminRouting,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: accept intent service: %w", err))
}
intentConsumer, err := worker.NewIntentConsumer(worker.IntentConsumerConfig{
Client: redisClient,
Stream: cfg.Streams.Intents,
BlockTimeout: cfg.IntentsReadBlockTimeout,
Acceptor: acceptIntentService,
MalformedRecorder: malformedIntentStore,
OffsetStore: streamOffsetStore,
Telemetry: telemetryRuntime,
}, logger)
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: intent consumer: %w", err))
}
runtime.intentConsumer = intentConsumer
pushPublisher, err := worker.NewPushPublisher(worker.PushPublisherConfig{
Store: acceptanceStore,
GatewayStream: cfg.Streams.GatewayClientEvents,
GatewayStreamMaxLen: cfg.Streams.GatewayClientEventsStreamMaxLen,
RouteLeaseTTL: cfg.Retry.RouteLeaseTTL,
RouteBackoffMin: cfg.Retry.RouteBackoffMin,
RouteBackoffMax: cfg.Retry.RouteBackoffMax,
Encoder: nil,
Telemetry: telemetryRuntime,
Clock: nil,
}, logger)
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: push publisher: %w", err))
}
runtime.pushPublisher = pushPublisher
emailPublisher, err := worker.NewEmailPublisher(worker.EmailPublisherConfig{
Store: acceptanceStore,
MailDeliveryCommandsStream: cfg.Streams.MailDeliveryCommands,
RouteLeaseTTL: cfg.Retry.RouteLeaseTTL,
RouteBackoffMin: cfg.Retry.RouteBackoffMin,
RouteBackoffMax: cfg.Retry.RouteBackoffMax,
Encoder: nil,
Telemetry: telemetryRuntime,
Clock: nil,
}, logger)
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: email publisher: %w", err))
}
runtime.emailPublisher = emailPublisher
probeServer, err := internalhttp.NewServer(internalhttp.Config{
Addr: cfg.InternalHTTP.Addr,
ReadHeaderTimeout: cfg.InternalHTTP.ReadHeaderTimeout,
ReadTimeout: cfg.InternalHTTP.ReadTimeout,
IdleTimeout: cfg.InternalHTTP.IdleTimeout,
}, internalhttp.Dependencies{
Logger: logger,
Telemetry: telemetryRuntime,
})
if err != nil {
return cleanupOnError(fmt.Errorf("new notification runtime: internal HTTP server: %w", err))
}
runtime.probeServer = probeServer
runtime.app = New(cfg, probeServer, intentConsumer, pushPublisher, emailPublisher)
return runtime, nil
}
// Run serves the private probe HTTP listener until ctx is canceled or one
// component fails.
func (runtime *Runtime) Run(ctx context.Context) error {
if ctx == nil {
return errors.New("run notification runtime: nil context")
}
if runtime == nil {
return errors.New("run notification runtime: nil runtime")
}
if runtime.app == nil {
return errors.New("run notification runtime: nil app")
}
return runtime.app.Run(ctx)
}
// Close releases every runtime dependency in reverse construction order.
func (runtime *Runtime) Close() error {
if runtime == nil {
return nil
}
var joined error
for index := len(runtime.cleanupFns) - 1; index >= 0; index-- {
if err := runtime.cleanupFns[index](); err != nil {
joined = errors.Join(joined, err)
}
}
return joined
}