This commit is contained in:
Anatoly Antonov 2026-05-18 03:17:17 +09:00
parent 7a8d5d13fa
commit 9e663db9dc
68 changed files with 5647 additions and 2958 deletions

181
cmd/predictor/main.go Normal file
View 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()
}