step one
This commit is contained in:
parent
7a8d5d13fa
commit
9e663db9dc
68 changed files with 5647 additions and 2958 deletions
181
cmd/predictor/main.go
Normal file
181
cmd/predictor/main.go
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
// Command predictor is the stratoflights-predictor HTTP server.
|
||||
//
|
||||
// It wires the configuration, dataset manager, scheduler, and API layer
|
||||
// into a single process and exits cleanly on SIGINT/SIGTERM.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/go-co-op/gocron"
|
||||
"go.uber.org/zap"
|
||||
"go.uber.org/zap/zapcore"
|
||||
|
||||
"predictor-refactored/internal/api"
|
||||
"predictor-refactored/internal/config"
|
||||
"predictor-refactored/internal/datasets"
|
||||
"predictor-refactored/internal/datasets/gfs"
|
||||
"predictor-refactored/internal/elevation"
|
||||
"predictor-refactored/internal/metrics"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "fatal:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(args []string) error {
|
||||
cfg, err := config.Load(args)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
log, err := newLogger(cfg.Log.Level)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init logger: %w", err)
|
||||
}
|
||||
defer log.Sync()
|
||||
|
||||
log.Info("configuration loaded",
|
||||
zap.Int("port", cfg.HTTP.Port),
|
||||
zap.String("data_dir", cfg.Data.Dir),
|
||||
zap.String("source", cfg.Data.Source),
|
||||
zap.Int("download_parallel", cfg.Download.Parallel),
|
||||
zap.Duration("update_interval", cfg.Download.UpdateInterval),
|
||||
zap.Duration("freshness_ttl", cfg.Download.FreshnessTTL),
|
||||
zap.Bool("metrics_enabled", cfg.Metrics.Enabled),
|
||||
)
|
||||
|
||||
store, err := datasets.NewLocalStore(cfg.Data.Dir, cfg.Data.Source)
|
||||
if err != nil {
|
||||
return fmt.Errorf("init store: %w", err)
|
||||
}
|
||||
|
||||
// Source is GFS today; the spec leaves room for ECMWF later via the
|
||||
// same datasets.Source interface.
|
||||
if cfg.Data.Source != "noaa-gfs-0p50" {
|
||||
return fmt.Errorf("source %q not supported", cfg.Data.Source)
|
||||
}
|
||||
source := gfs.NewSource(log)
|
||||
source.Parallel = cfg.Download.Parallel
|
||||
|
||||
var throttle datasets.Throttle
|
||||
if cfg.Download.BandwidthBytesPerSecond > 0 {
|
||||
throttle = datasets.NewTokenBucket(cfg.Download.BandwidthBytesPerSecond)
|
||||
}
|
||||
|
||||
// Metrics (optional).
|
||||
var sink metrics.Sink = metrics.Noop()
|
||||
var metricsHandler http.Handler
|
||||
if cfg.Metrics.Enabled {
|
||||
prom := metrics.NewProm()
|
||||
sink = prom
|
||||
metricsHandler = prom
|
||||
}
|
||||
|
||||
mgr := datasets.New(source, store, throttle, log)
|
||||
defer mgr.Close()
|
||||
|
||||
// Optional elevation dataset. Missing or unreadable elevation is logged
|
||||
// but non-fatal; descent terminates at sea level instead.
|
||||
var elev *elevation.Dataset
|
||||
if cfg.Data.ElevationPath != "" {
|
||||
if d, err := elevation.Open(cfg.Data.ElevationPath); err == nil {
|
||||
elev = d
|
||||
log.Info("elevation dataset loaded", zap.String("path", cfg.Data.ElevationPath))
|
||||
defer elev.Close()
|
||||
} else {
|
||||
log.Warn("elevation dataset not available, using sea-level termination",
|
||||
zap.String("path", cfg.Data.ElevationPath),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
// Kick off the initial refresh in the background so the server can start
|
||||
// answering /ready immediately.
|
||||
go func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
if _, err := mgr.Refresh(ctx, cfg.Download.FreshnessTTL); err != nil {
|
||||
log.Error("initial dataset refresh failed", zap.Error(err))
|
||||
}
|
||||
if a := mgr.Active(); a != nil {
|
||||
sink.ActiveEpoch(a.Epoch())
|
||||
}
|
||||
}()
|
||||
|
||||
scheduler := gocron.NewScheduler(time.UTC)
|
||||
scheduler.Every(cfg.Download.UpdateInterval).Do(func() {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Minute)
|
||||
defer cancel()
|
||||
log.Info("scheduled dataset refresh starting")
|
||||
if _, err := mgr.Refresh(ctx, cfg.Download.FreshnessTTL); err != nil {
|
||||
log.Error("scheduled dataset refresh failed", zap.Error(err))
|
||||
}
|
||||
if a := mgr.Active(); a != nil {
|
||||
sink.ActiveEpoch(a.Epoch())
|
||||
}
|
||||
})
|
||||
scheduler.StartAsync()
|
||||
defer scheduler.Stop()
|
||||
|
||||
server, err := api.New(cfg.HTTP.Port, api.Deps{
|
||||
Manager: mgr,
|
||||
Elevation: elev,
|
||||
Metrics: sink,
|
||||
MetricsHandler: metricsHandler,
|
||||
MetricsPath: cfg.Metrics.Path,
|
||||
Log: log,
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("init server: %w", err)
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
ctx, cancel := signalContext()
|
||||
defer cancel()
|
||||
|
||||
log.Info("service started")
|
||||
if err := server.Run(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
return fmt.Errorf("http server: %w", err)
|
||||
}
|
||||
log.Info("service stopped")
|
||||
return nil
|
||||
}
|
||||
|
||||
func signalContext() (context.Context, context.CancelFunc) {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
sigCh := make(chan os.Signal, 1)
|
||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||
go func() {
|
||||
<-sigCh
|
||||
cancel()
|
||||
}()
|
||||
return ctx, cancel
|
||||
}
|
||||
|
||||
func newLogger(level string) (*zap.Logger, error) {
|
||||
cfg := zap.NewProductionConfig()
|
||||
switch level {
|
||||
case "debug":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
|
||||
case "info":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
case "warn":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.WarnLevel)
|
||||
case "error":
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.ErrorLevel)
|
||||
default:
|
||||
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
|
||||
}
|
||||
return cfg.Build()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue