feat: refactor
This commit is contained in:
parent
82ef1cb3b8
commit
51bbf3c579
44 changed files with 8589 additions and 0 deletions
245
internal/service/service.go
Normal file
245
internal/service/service.go
Normal file
|
|
@ -0,0 +1,245 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"predictor-refactored/internal/dataset"
|
||||
"predictor-refactored/internal/downloader"
|
||||
"predictor-refactored/internal/elevation"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Service orchestrates the dataset lifecycle and provides prediction capabilities.
|
||||
type Service struct {
|
||||
mu sync.RWMutex
|
||||
ds *dataset.File
|
||||
elev *elevation.Dataset
|
||||
cfg *downloader.Config
|
||||
dl *downloader.Downloader
|
||||
log *zap.Logger
|
||||
updating sync.Mutex // prevents concurrent downloads
|
||||
}
|
||||
|
||||
// New creates a new Service.
|
||||
func New(cfg *downloader.Config, log *zap.Logger) *Service {
|
||||
return &Service{
|
||||
cfg: cfg,
|
||||
dl: downloader.NewDownloader(cfg, log),
|
||||
log: log,
|
||||
}
|
||||
}
|
||||
|
||||
// LoadElevation loads the ruaumoko-compatible elevation dataset from path.
|
||||
// If the file doesn't exist, elevation termination is disabled (falls back to sea level).
|
||||
func (s *Service) LoadElevation(path string) {
|
||||
ds, err := elevation.Open(path)
|
||||
if err != nil {
|
||||
s.log.Warn("elevation dataset not available, using sea-level termination",
|
||||
zap.String("path", path), zap.Error(err))
|
||||
return
|
||||
}
|
||||
s.elev = ds
|
||||
s.log.Info("elevation dataset loaded", zap.String("path", path))
|
||||
}
|
||||
|
||||
// Elevation returns the elevation dataset (may be nil).
|
||||
func (s *Service) Elevation() *elevation.Dataset {
|
||||
return s.elev
|
||||
}
|
||||
|
||||
// Ready returns true if the service has a loaded dataset.
|
||||
func (s *Service) Ready() bool {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.ds != nil
|
||||
}
|
||||
|
||||
// DatasetTime returns the forecast time of the currently loaded dataset.
|
||||
func (s *Service) DatasetTime() (time.Time, bool) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.ds == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return s.ds.DSTime, true
|
||||
}
|
||||
|
||||
// Dataset returns the current dataset for reading.
|
||||
func (s *Service) Dataset() *dataset.File {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.ds
|
||||
}
|
||||
|
||||
// Update checks for and downloads new forecast data if needed.
|
||||
func (s *Service) Update(ctx context.Context) error {
|
||||
if !s.updating.TryLock() {
|
||||
s.log.Info("update already in progress, skipping")
|
||||
return nil
|
||||
}
|
||||
defer s.updating.Unlock()
|
||||
|
||||
// Check if current dataset is still fresh
|
||||
if dsTime, ok := s.DatasetTime(); ok {
|
||||
if time.Since(dsTime) < s.cfg.DatasetTTL {
|
||||
s.log.Info("dataset still fresh, skipping update",
|
||||
zap.Time("dataset_time", dsTime),
|
||||
zap.Duration("age", time.Since(dsTime)))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Try loading an existing dataset from disk first
|
||||
if err := s.loadExistingDataset(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Find latest available model run
|
||||
run, err := s.dl.FindLatestRun(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Download and assemble
|
||||
path, err := s.dl.Download(ctx, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Open the new dataset
|
||||
ds, err := dataset.Open(path, run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Swap in the new dataset
|
||||
s.setDataset(ds)
|
||||
s.log.Info("dataset loaded", zap.Time("run", run), zap.String("path", path))
|
||||
|
||||
// Clean old datasets
|
||||
s.cleanOldDatasets(path)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadExistingDataset tries to find and load an existing dataset from the data directory.
|
||||
func (s *Service) loadExistingDataset() error {
|
||||
entries, err := os.ReadDir(s.cfg.DataDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Collect valid dataset files (name is YYYYMMDDHH, no extension, correct size)
|
||||
type candidate struct {
|
||||
name string
|
||||
path string
|
||||
run time.Time
|
||||
}
|
||||
var candidates []candidate
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || strings.Contains(e.Name(), ".") {
|
||||
continue
|
||||
}
|
||||
if len(e.Name()) != 10 {
|
||||
continue
|
||||
}
|
||||
|
||||
run, err := time.Parse("2006010215", e.Name())
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
path := filepath.Join(s.cfg.DataDir, e.Name())
|
||||
info, err := os.Stat(path)
|
||||
if err != nil || info.Size() != dataset.DatasetSize {
|
||||
continue
|
||||
}
|
||||
|
||||
if time.Since(run) > s.cfg.DatasetTTL {
|
||||
continue
|
||||
}
|
||||
|
||||
candidates = append(candidates, candidate{name: e.Name(), path: path, run: run})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return os.ErrNotExist
|
||||
}
|
||||
|
||||
// Pick the newest
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
return candidates[i].run.After(candidates[j].run)
|
||||
})
|
||||
|
||||
best := candidates[0]
|
||||
ds, err := dataset.Open(best.path, best.run)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s.setDataset(ds)
|
||||
s.log.Info("loaded existing dataset",
|
||||
zap.Time("run", best.run),
|
||||
zap.String("path", best.path))
|
||||
return nil
|
||||
}
|
||||
|
||||
// setDataset swaps the current dataset with a new one, closing the old one.
|
||||
func (s *Service) setDataset(ds *dataset.File) {
|
||||
s.mu.Lock()
|
||||
old := s.ds
|
||||
s.ds = ds
|
||||
s.mu.Unlock()
|
||||
|
||||
if old != nil {
|
||||
if err := old.Close(); err != nil {
|
||||
s.log.Error("failed to close old dataset", zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// cleanOldDatasets removes dataset files other than the one at keepPath.
|
||||
func (s *Service) cleanOldDatasets(keepPath string) {
|
||||
entries, err := os.ReadDir(s.cfg.DataDir)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for _, e := range entries {
|
||||
if e.IsDir() {
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(s.cfg.DataDir, e.Name())
|
||||
if path == keepPath {
|
||||
continue
|
||||
}
|
||||
// Remove old datasets and temp files
|
||||
if len(e.Name()) == 10 || strings.HasSuffix(e.Name(), ".downloading") {
|
||||
if err := os.Remove(path); err != nil {
|
||||
s.log.Warn("failed to remove old file", zap.String("path", path), zap.Error(err))
|
||||
} else {
|
||||
s.log.Info("removed old dataset", zap.String("path", path))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close releases all resources.
|
||||
func (s *Service) Close() error {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.ds != nil {
|
||||
err := s.ds.Close()
|
||||
s.ds = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue