Files
MrAlders0n ec0037860c fix(meshmapper): count cut-short requests and qualify groups by heard IATAs
Record each attempt before sending so a request lost to a deadline still
waits out MeshMapper's limit, and stop holding the directory lock across
HTTP. Groups now qualify only through heard or configured IATAs, and one
missing country list only skips pruning.
2026-10-02 16:53:32 -04:00

228 lines
6.9 KiB
Go

// Copyright 2026 Beacon Contributors
// SPDX-License-Identifier: AGPL-3.0-or-later
package meshmapper
import (
"cmp"
"context"
"fmt"
"log/slog"
"maps"
"slices"
"strings"
)
// RegionState is a stored region; imported ones are also what SaveImportedRegion writes.
type RegionState struct {
Slug, Name string
DisplayOrder int
Imported bool
IATAs []string
CenterLat, CenterLng *float64
}
// IATADetails is an IATA's display name and approximate location.
type IATADetails struct {
IATA string
Name string
Lat, Lng *float64
}
// SetConfiguredIATAs names the IATAs whose config entry sets a name or location; those win.
func (z *Zones) SetConfiguredIATAs(iatas []string) {
z.configured = map[string]bool{}
for _, iata := range iatas {
z.configured[iata] = true
}
}
// OnIATAsChange is told whenever imported IATA names or locations change.
func (z *Zones) OnIATAsChange(fn func(ctx context.Context)) { z.onIATAs = fn }
// OnRegionsChange is told whenever the imported regions change, for cache invalidation.
func (z *Zones) OnRegionsChange(fn func(ctx context.Context)) { z.onRegions = fn }
// pruneGroups drops every imported region when group import is off.
func (z *Zones) pruneGroups(ctx context.Context) error {
pruned, err := z.store.PruneImportedRegions(ctx, nil)
if err != nil {
return fmt.Errorf("prune MeshMapper regions: %w", err)
}
z.regionsRemoved(pruned)
if len(pruned) > 0 {
z.regionsChanged(ctx)
}
return nil
}
// syncInputs is what syncDirectory last applied; it reruns when any of them changes.
type syncInputs struct {
version, known int
complete bool
heard string
}
// syncDirectory applies the loaded zone lists to IATA details and, when enabled,
// imported regions. Groups are only pruned once every tracked country has a list.
func (z *Zones) syncDirectory(ctx context.Context, iatas []string) error {
var countries []string
for _, r := range z.regions {
countries = append(countries, r.country)
}
slices.Sort(countries)
groups, zones, version, complete := z.dir.snapshot(slices.Compact(countries))
var heard []string
if z.importGroups {
var err error
if heard, err = z.store.ListHeardIATAs(ctx); err != nil {
return fmt.Errorf("list heard IATAs for MeshMapper groups: %w", err)
}
slices.Sort(heard)
}
inputs := syncInputs{version: version, known: len(iatas), complete: complete, heard: strings.Join(heard, ",")}
if z.groupsSynced && inputs == z.synced {
return nil
}
if err := z.syncIATADetails(ctx, zones); err != nil {
return err
}
if z.importGroups {
if err := z.syncGroups(ctx, heard, groups, zones, complete); err != nil {
return err
}
}
z.groupsSynced, z.synced = true, inputs
return nil
}
// syncIATADetails names and locates each listed IATA unless its config entry does.
func (z *Zones) syncIATADetails(ctx context.Context, zones map[string]zoneEntry) error {
stored, err := z.store.ListIATADetails(ctx)
if err != nil {
return fmt.Errorf("list IATAs for MeshMapper details: %w", err)
}
changed := false
for _, cur := range stored {
zone, ok := zones[cur.IATA]
if !ok || z.configured[cur.IATA] || zone.name == "" && zone.lat == nil {
continue
}
name := cmp.Or(zone.name, cur.Name)
lat, lng := cur.Lat, cur.Lng
if zone.lat != nil {
lat, lng = zone.lat, zone.lon
}
if name == cur.Name && sameFloat(lat, cur.Lat) && sameFloat(lng, cur.Lng) {
continue
}
if err := z.store.UpsertIATADetails(ctx, cur.IATA, name, lat, lng); err != nil {
return fmt.Errorf("save MeshMapper details for %s: %w", cur.IATA, err)
}
changed = true
}
if changed && z.onIATAs != nil {
z.onIATAs(ctx)
}
return nil
}
// syncGroups imports groups with a member that is heard or configured, never one only
// its own import created. Without every country's list it adds and updates but never prunes.
func (z *Zones) syncGroups(ctx context.Context, heard []string, groups map[string]zoneGroup, zones map[string]zoneEntry, complete bool) error {
inUse := maps.Clone(z.configured)
if inUse == nil {
inUse = map[string]bool{}
}
for _, iata := range heard {
inUse[iata] = true
}
state, err := z.store.ListRegionState(ctx)
if err != nil {
return fmt.Errorf("list regions for MeshMapper groups: %w", err)
}
stored, handOrder := map[string]RegionState{}, 0
for _, r := range state {
stored[r.Slug] = r
if !r.Imported {
handOrder = max(handOrder, r.DisplayOrder)
for _, iata := range r.IATAs {
inUse[iata] = true
}
}
}
var want []RegionState
for code, g := range groups {
if slices.ContainsFunc(g.members, func(m string) bool { return inUse[m] }) {
lat, lng := center(g.members, zones)
want = append(want, RegionState{Slug: strings.ToLower(code), Name: g.name, Imported: true, IATAs: g.members, CenterLat: lat, CenterLng: lng})
}
}
slices.SortFunc(want, func(a, b RegionState) int { return cmp.Or(cmp.Compare(a.Name, b.Name), cmp.Compare(a.Slug, b.Slug)) })
keep, changed := []string{}, false
for n, r := range want {
r.DisplayOrder = handOrder + 1 + n
if cur, ok := stored[r.Slug]; ok && !cur.Imported {
slog.Info("MeshMapper group skipped: a configured region uses its slug", "component", "meshmapper.zones", "slug", r.Slug)
continue
} else if ok && cur.Name == r.Name && cur.DisplayOrder == r.DisplayOrder && slices.Equal(cur.IATAs, r.IATAs) &&
sameFloat(cur.CenterLat, r.CenterLat) && sameFloat(cur.CenterLng, r.CenterLng) {
keep = append(keep, r.Slug)
continue
}
saved, err := z.store.SaveImportedRegion(ctx, r)
if err != nil {
return fmt.Errorf("save MeshMapper region %s: %w", r.Slug, err)
}
if !saved {
slog.Info("MeshMapper group skipped: a configured region uses its slug", "component", "meshmapper.zones", "slug", r.Slug)
continue
}
keep, changed = append(keep, r.Slug), true
slog.Info("MeshMapper region imported", "component", "meshmapper.zones", "slug", r.Slug, "name", r.Name, "iatas", r.IATAs)
}
var pruned []string
if complete {
if pruned, err = z.store.PruneImportedRegions(ctx, keep); err != nil {
return fmt.Errorf("prune MeshMapper regions: %w", err)
}
z.regionsRemoved(pruned)
}
if changed || len(pruned) > 0 {
z.regionsChanged(ctx)
}
return nil
}
// center averages the members' zone locations; nil when none has one.
func center(members []string, zones map[string]zoneEntry) (*float64, *float64) {
var lat, lng float64
n := 0
for _, m := range members {
if z := zones[m]; z.lat != nil {
lat, lng, n = lat+*z.lat, lng+*z.lon, n+1
}
}
if n == 0 {
return nil, nil
}
lat, lng = lat/float64(n), lng/float64(n)
return &lat, &lng
}
func sameFloat(a, b *float64) bool {
return a == nil && b == nil || a != nil && b != nil && *a == *b
}
func (z *Zones) regionsRemoved(slugs []string) {
for _, slug := range slugs {
slog.Info("MeshMapper region removed", "component", "meshmapper.zones", "slug", slug)
}
}
func (z *Zones) regionsChanged(ctx context.Context) {
if z.onRegions != nil {
z.onRegions(ctx)
}
}