371 lines
12 KiB
Go
371 lines
12 KiB
Go
package app
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"time"
|
|
|
|
"galaxy/mail/internal/adapters/id"
|
|
"galaxy/mail/internal/adapters/redisstate"
|
|
templatedir "galaxy/mail/internal/adapters/templates"
|
|
"galaxy/mail/internal/api/internalhttp"
|
|
"galaxy/mail/internal/config"
|
|
"galaxy/mail/internal/service/acceptauthdelivery"
|
|
"galaxy/mail/internal/service/acceptgenericdelivery"
|
|
"galaxy/mail/internal/service/executeattempt"
|
|
"galaxy/mail/internal/service/getdelivery"
|
|
"galaxy/mail/internal/service/listattempts"
|
|
"galaxy/mail/internal/service/listdeliveries"
|
|
"galaxy/mail/internal/service/renderdelivery"
|
|
"galaxy/mail/internal/service/resenddelivery"
|
|
"galaxy/mail/internal/telemetry"
|
|
"galaxy/mail/internal/worker"
|
|
"galaxy/mail/internal/ports"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// Runtime owns the runnable Mail Service process plus the cleanup functions
|
|
// that release runtime resources after shutdown.
|
|
type Runtime struct {
|
|
cfg config.Config
|
|
|
|
app *App
|
|
|
|
templateCatalog *templatedir.Catalog
|
|
renderDeliveryService *renderdelivery.Service
|
|
|
|
cleanupFns []func() error
|
|
}
|
|
|
|
type runtimeClock interface {
|
|
Now() time.Time
|
|
}
|
|
|
|
type runtimeProviderFactory func(config.SMTPConfig, *slog.Logger) (ports.Provider, error)
|
|
|
|
type runtimeDependencies struct {
|
|
clock runtimeClock
|
|
providerFactory runtimeProviderFactory
|
|
schedulerPoll time.Duration
|
|
schedulerRecovery time.Duration
|
|
schedulerGrace time.Duration
|
|
}
|
|
|
|
func (deps runtimeDependencies) withDefaults() runtimeDependencies {
|
|
if deps.clock == nil {
|
|
deps.clock = systemClock{}
|
|
}
|
|
if deps.providerFactory == nil {
|
|
deps.providerFactory = newProvider
|
|
}
|
|
|
|
return deps
|
|
}
|
|
|
|
// NewRuntime constructs the runnable Mail Service process from cfg.
|
|
func NewRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger) (*Runtime, error) {
|
|
return newRuntime(ctx, cfg, logger, runtimeDependencies{})
|
|
}
|
|
|
|
func newRuntime(ctx context.Context, cfg config.Config, logger *slog.Logger, deps runtimeDependencies) (*Runtime, error) {
|
|
if ctx == nil {
|
|
return nil, fmt.Errorf("new mail runtime: nil context")
|
|
}
|
|
if err := cfg.Validate(); err != nil {
|
|
return nil, fmt.Errorf("new mail runtime: %w", err)
|
|
}
|
|
if logger == nil {
|
|
logger = slog.Default()
|
|
}
|
|
deps = deps.withDefaults()
|
|
|
|
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)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: telemetry: %w", err))
|
|
}
|
|
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), cfg.ShutdownTimeout)
|
|
defer cancel()
|
|
return telemetryRuntime.Shutdown(shutdownCtx)
|
|
})
|
|
|
|
redisClient := newRedisClient(cfg.Redis)
|
|
if err := instrumentRedisClient(redisClient, telemetryRuntime); err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
|
}
|
|
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
|
return redisClient.Close()
|
|
})
|
|
if err := pingRedis(ctx, cfg.Redis, redisClient); err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
|
}
|
|
|
|
templateCatalog, err := newTemplateCatalog(cfg.Templates)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
|
}
|
|
runtime.templateCatalog = templateCatalog
|
|
|
|
provider, err := deps.providerFactory(cfg.SMTP, logger.With("component", "provider"))
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
|
}
|
|
runtime.cleanupFns = append(runtime.cleanupFns, provider.Close)
|
|
|
|
acceptanceStore, err := redisstate.NewAcceptanceStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance store: %w", err))
|
|
}
|
|
authAcceptanceService, err := acceptauthdelivery.New(acceptauthdelivery.Config{
|
|
Store: acceptanceStore,
|
|
DeliveryIDGenerator: id.Generator{},
|
|
Clock: deps.clock,
|
|
Telemetry: telemetryRuntime,
|
|
TracerProvider: telemetryRuntime.TracerProvider(),
|
|
Logger: logger,
|
|
IdempotencyTTL: redisstate.IdempotencyTTL,
|
|
SuppressOutbound: cfg.SMTP.Mode == config.SMTPModeStub,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: auth acceptance service: %w", err))
|
|
}
|
|
|
|
genericAcceptanceStore, err := redisstate.NewGenericAcceptanceStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance store: %w", err))
|
|
}
|
|
genericAcceptanceService, err := acceptgenericdelivery.New(acceptgenericdelivery.Config{
|
|
Store: genericAcceptanceStore,
|
|
Clock: deps.clock,
|
|
Telemetry: telemetryRuntime,
|
|
TracerProvider: telemetryRuntime.TracerProvider(),
|
|
Logger: logger,
|
|
IdempotencyTTL: redisstate.IdempotencyTTL,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: generic acceptance service: %w", err))
|
|
}
|
|
|
|
renderStore, err := redisstate.NewRenderStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: render store: %w", err))
|
|
}
|
|
renderDeliveryService, err := renderdelivery.New(renderdelivery.Config{
|
|
Catalog: templateCatalog,
|
|
Store: renderStore,
|
|
Clock: deps.clock,
|
|
Telemetry: telemetryRuntime,
|
|
TracerProvider: telemetryRuntime.TracerProvider(),
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: render delivery service: %w", err))
|
|
}
|
|
runtime.renderDeliveryService = renderDeliveryService
|
|
|
|
malformedCommandStore, err := redisstate.NewMalformedCommandStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: malformed command store: %w", err))
|
|
}
|
|
streamOffsetStore, err := redisstate.NewStreamOffsetStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: stream offset store: %w", err))
|
|
}
|
|
attemptExecutionStore, err := redisstate.NewAttemptExecutionStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: attempt execution store: %w", err))
|
|
}
|
|
telemetryRuntime.SetAttemptScheduleSnapshotReader(attemptExecutionStore)
|
|
operatorStore, err := redisstate.NewOperatorStore(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: operator store: %w", err))
|
|
}
|
|
attemptExecutionService, err := executeattempt.New(executeattempt.Config{
|
|
Renderer: renderDeliveryService,
|
|
Provider: provider,
|
|
PayloadLoader: attemptExecutionStore,
|
|
Store: attemptExecutionStore,
|
|
Clock: deps.clock,
|
|
Telemetry: telemetryRuntime,
|
|
TracerProvider: telemetryRuntime.TracerProvider(),
|
|
Logger: logger,
|
|
AttemptTimeout: cfg.SMTP.Timeout,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: attempt execution service: %w", err))
|
|
}
|
|
listDeliveriesService, err := listdeliveries.New(listdeliveries.Config{
|
|
Store: operatorStore,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: list deliveries service: %w", err))
|
|
}
|
|
getDeliveryService, err := getdelivery.New(getdelivery.Config{
|
|
Store: operatorStore,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: get delivery service: %w", err))
|
|
}
|
|
listAttemptsService, err := listattempts.New(listattempts.Config{
|
|
Store: operatorStore,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: list attempts service: %w", err))
|
|
}
|
|
resendDeliveryService, err := resenddelivery.New(resenddelivery.Config{
|
|
Store: operatorStore,
|
|
DeliveryIDGenerator: id.Generator{},
|
|
Clock: deps.clock,
|
|
Telemetry: telemetryRuntime,
|
|
TracerProvider: telemetryRuntime.TracerProvider(),
|
|
Logger: logger,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: resend delivery service: %w", err))
|
|
}
|
|
|
|
commandConsumerRedisClient := newRedisClient(cfg.Redis)
|
|
if err := instrumentRedisClient(commandConsumerRedisClient, telemetryRuntime); err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
|
}
|
|
runtime.cleanupFns = append(runtime.cleanupFns, func() error {
|
|
err := commandConsumerRedisClient.Close()
|
|
if errors.Is(err, redis.ErrClosed) {
|
|
return nil
|
|
}
|
|
return err
|
|
})
|
|
if err := pingRedis(ctx, cfg.Redis, commandConsumerRedisClient); err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: %w", err))
|
|
}
|
|
|
|
httpServer, 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,
|
|
AcceptLoginCodeDelivery: authAcceptanceService,
|
|
ListDeliveries: listDeliveriesService,
|
|
GetDelivery: getDeliveryService,
|
|
ListAttempts: listAttemptsService,
|
|
ResendDelivery: resendDeliveryService,
|
|
OperatorRequestTimeout: cfg.OperatorRequestTimeout,
|
|
})
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: internal HTTP server: %w", err))
|
|
}
|
|
|
|
commandConsumer, err := worker.NewCommandConsumer(worker.CommandConsumerConfig{
|
|
Client: commandConsumerRedisClient,
|
|
Stream: cfg.Redis.CommandStream,
|
|
BlockTimeout: cfg.StreamBlockTimeout,
|
|
Acceptor: genericAcceptanceService,
|
|
MalformedRecorder: malformedCommandStore,
|
|
OffsetStore: streamOffsetStore,
|
|
Telemetry: telemetryRuntime,
|
|
Clock: deps.clock,
|
|
}, logger)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: command consumer: %w", err))
|
|
}
|
|
attemptWorkQueue := make(chan executeattempt.WorkItem, cfg.AttemptWorkerConcurrency)
|
|
scheduler, err := worker.NewScheduler(worker.SchedulerConfig{
|
|
Store: attemptExecutionStore,
|
|
Service: attemptExecutionService,
|
|
WorkQueue: attemptWorkQueue,
|
|
Clock: deps.clock,
|
|
AttemptTimeout: cfg.SMTP.Timeout,
|
|
Telemetry: telemetryRuntime,
|
|
PollInterval: deps.schedulerPoll,
|
|
RecoveryInterval: deps.schedulerRecovery,
|
|
RecoveryGrace: deps.schedulerGrace,
|
|
}, logger)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: scheduler: %w", err))
|
|
}
|
|
attemptWorkers, err := worker.NewAttemptWorkerPool(worker.AttemptWorkerPoolConfig{
|
|
Concurrency: cfg.AttemptWorkerConcurrency,
|
|
WorkQueue: attemptWorkQueue,
|
|
Service: attemptExecutionService,
|
|
}, logger)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: attempt worker pool: %w", err))
|
|
}
|
|
indexCleaner, err := redisstate.NewIndexCleaner(redisClient)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: cleanup index cleaner: %w", err))
|
|
}
|
|
cleanupWorker, err := worker.NewCleanupWorker(indexCleaner, logger)
|
|
if err != nil {
|
|
return cleanupOnError(fmt.Errorf("new mail runtime: cleanup worker: %w", err))
|
|
}
|
|
|
|
runtime.app = New(cfg, httpServer, commandConsumer, scheduler, attemptWorkers, cleanupWorker)
|
|
|
|
return runtime, nil
|
|
}
|
|
|
|
type systemClock struct{}
|
|
|
|
func (systemClock) Now() time.Time {
|
|
return time.Now()
|
|
}
|
|
|
|
// Run serves the internal HTTP listener and background workers until ctx is
|
|
// canceled or one component fails.
|
|
func (runtime *Runtime) Run(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return errors.New("run mail runtime: nil context")
|
|
}
|
|
if runtime == nil {
|
|
return errors.New("run mail runtime: nil runtime")
|
|
}
|
|
if runtime.app == nil {
|
|
return errors.New("run mail 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
|
|
}
|