github.com/m-lab/locate@v0.17.6/heartbeat/location.go (about)

     1  package heartbeat
     2  
     3  import (
     4  	"errors"
     5  	"math/rand"
     6  	"net/url"
     7  	"sort"
     8  	"strconv"
     9  
    10  	"github.com/m-lab/go/host"
    11  	"github.com/m-lab/go/mathx"
    12  	v2 "github.com/m-lab/locate/api/v2"
    13  	"github.com/m-lab/locate/metrics"
    14  	"github.com/m-lab/locate/static"
    15  )
    16  
    17  var (
    18  	// ErrNoAvailableServers is returned when there are no available servers
    19  	ErrNoAvailableServers = errors.New("no available M-Lab servers")
    20  )
    21  
    22  // Locator manages requests to "locate" mlab-ns servers.
    23  type Locator struct {
    24  	StatusTracker
    25  }
    26  
    27  // NearestOptions allows clients to pass parameters modifying how results are
    28  // filtered.
    29  type NearestOptions struct {
    30  	Type    string   // Limit results to only machines of this type.
    31  	Sites   []string // Limit results to only machines at these sites.
    32  	Country string   // Bias results to prefer machines in this country.
    33  	Org     string   // Limit results to only machines from this organization.
    34  	Strict  bool     // When used with Country, limit results to only machines in this country.
    35  }
    36  
    37  // TargetInfo returns the set of `v2.Target` to run the measurement on with the
    38  // necessary information to create their URLs.
    39  type TargetInfo struct {
    40  	Targets []v2.Target    // Targets to run a measurement on.
    41  	URLs    []url.URL      // Service URL templates.
    42  	Ranks   map[string]int // Map of machines to metro rankings.
    43  }
    44  
    45  // machine associates a machine name with its v2.Health value.
    46  type machine struct {
    47  	name   string
    48  	host   string
    49  	health v2.Health
    50  }
    51  
    52  // site groups v2.HeartbeatMessage instances based on v2.Registration.Site.
    53  type site struct {
    54  	distance     float64
    55  	rank         int
    56  	metroRank    int
    57  	registration v2.Registration
    58  	machines     []machine
    59  }
    60  
    61  // StatusTracker defines the interface for tracking the status of experiment instances.
    62  type StatusTracker interface {
    63  	RegisterInstance(rm v2.Registration) error
    64  	UpdateHealth(hostname string, hm v2.Health) error
    65  	UpdatePrometheus(hostnames, machines map[string]bool) error
    66  	Instances() map[string]v2.HeartbeatMessage
    67  	StopImport()
    68  	Ready() bool
    69  }
    70  
    71  // NewServerLocator creates a new Locator instance.
    72  func NewServerLocator(tracker StatusTracker) *Locator {
    73  	return &Locator{
    74  		StatusTracker: tracker,
    75  	}
    76  }
    77  
    78  // Nearest discovers the nearest machines for the target service, using
    79  // an exponentially distributed function based on distance.
    80  func (l *Locator) Nearest(service string, lat, lon float64, opts *NearestOptions) (*TargetInfo, error) {
    81  	// Filter.
    82  	sites := filterSites(service, lat, lon, l.Instances(), opts)
    83  
    84  	// Sort.
    85  	sortSites(sites)
    86  
    87  	// Rank.
    88  	rank(sites)
    89  
    90  	// Pick.
    91  	result := pickTargets(service, sites)
    92  
    93  	if len(result.Targets) == 0 {
    94  		return nil, ErrNoAvailableServers
    95  	}
    96  
    97  	return result, nil
    98  }
    99  
   100  // filterSites groups the v2.HeartbeatMessage instances into sites and returns
   101  // only those that can serve the client request.
   102  func filterSites(service string, lat, lon float64, instances map[string]v2.HeartbeatMessage, opts *NearestOptions) []site {
   103  	m := make(map[string]*site)
   104  
   105  	for _, v := range instances {
   106  		isValid, machineName, distance := isValidInstance(service, lat, lon, v, opts)
   107  		if !isValid {
   108  			continue
   109  		}
   110  
   111  		r := v.Registration
   112  		s, ok := m[r.Site]
   113  		if !ok {
   114  			s = &site{
   115  				distance:     distance,
   116  				registration: *r,
   117  				machines:     make([]machine, 0),
   118  			}
   119  			s.registration.Hostname = ""
   120  			s.registration.Machine = ""
   121  			m[r.Site] = s
   122  		}
   123  		s.machines = append(s.machines, machine{
   124  			name:   machineName.String(),
   125  			host:   machineName.StringWithService(),
   126  			health: *v.Health})
   127  	}
   128  
   129  	sites := make([]site, 0)
   130  	for _, v := range m {
   131  		if alwaysPick(opts) || pickWithProbability(v.registration.Probability) {
   132  			sites = append(sites, *v)
   133  		}
   134  	}
   135  
   136  	return sites
   137  }
   138  
   139  // isValidInstance returns whether a v2.HeartbeatMessage signals a valid
   140  // instance that can serve a request given its parameters.
   141  func isValidInstance(service string, lat, lon float64, v v2.HeartbeatMessage, opts *NearestOptions) (bool, host.Name, float64) {
   142  	if !isHealthy(v) {
   143  		return false, host.Name{}, 0
   144  	}
   145  
   146  	r := v.Registration
   147  
   148  	machineName, err := host.Parse(r.Hostname)
   149  	if err != nil {
   150  		return false, host.Name{}, 0
   151  	}
   152  
   153  	if opts.Type != "" && opts.Type != r.Type {
   154  		return false, host.Name{}, 0
   155  	}
   156  
   157  	if opts.Sites != nil && !contains(opts.Sites, r.Site) {
   158  		return false, host.Name{}, 0
   159  	}
   160  
   161  	if opts.Country != "" && opts.Strict && r.CountryCode != opts.Country {
   162  		return false, host.Name{}, 0
   163  	}
   164  
   165  	if opts.Org != "" {
   166  		// We are filtering on user-specified organization.
   167  		if opts.Org != "mlab" && machineName.Version == "v2" {
   168  			// All v2 names are "mlab" managed.
   169  			return false, host.Name{}, 0
   170  		}
   171  		if machineName.Version == "v3" && opts.Org != machineName.Org {
   172  			return false, host.Name{}, 0
   173  		}
   174  		// NOTE: Org == "mlab" will allow all v2 names.
   175  	}
   176  
   177  	if _, ok := r.Services[service]; !ok {
   178  		return false, host.Name{}, 0
   179  	}
   180  
   181  	distance := mathx.GetHaversineDistance(lat, lon, r.Latitude, r.Longitude)
   182  	if distance > static.EarthHalfCircumferenceKm {
   183  		return false, host.Name{}, 0
   184  	}
   185  
   186  	return true, machineName, distance
   187  }
   188  
   189  func isHealthy(v v2.HeartbeatMessage) bool {
   190  	if v.Registration == nil || v.Health == nil || v.Health.Score == 0 {
   191  		return false
   192  	}
   193  
   194  	if v.Prometheus != nil && !v.Prometheus.Health {
   195  		return false
   196  	}
   197  
   198  	return true
   199  }
   200  
   201  // contains reports whether the given string array contains the given value.
   202  func contains(sa []string, value string) bool {
   203  	for _, v := range sa {
   204  		if v == value {
   205  			return true
   206  		}
   207  	}
   208  	return false
   209  }
   210  
   211  // sortSites sorts a []site in ascending order based on distance.
   212  func sortSites(sites []site) {
   213  	sort.Slice(sites, func(i, j int) bool {
   214  		return sites[i].distance < sites[j].distance
   215  	})
   216  }
   217  
   218  // rank ranks sites and metros.
   219  func rank(sites []site) {
   220  	metroRank := 0
   221  	metros := make(map[string]int)
   222  	for i, site := range sites {
   223  		// Update site rank.
   224  		sites[i].rank = i
   225  
   226  		// Update metro rank.
   227  		metro := site.registration.Metro
   228  		_, ok := metros[metro]
   229  		if !ok {
   230  			metros[metro] = metroRank
   231  			metroRank++
   232  		}
   233  		sites[i].metroRank = metros[metro]
   234  	}
   235  }
   236  
   237  // pickTargets picks up to 4 sites using an exponentially distributed function based
   238  // on distance. For each site, it picks a machine at random and returns them
   239  // as []v2.Target.
   240  // For any of the picked targets, it also returns the service URL templates as []url.URL.
   241  func pickTargets(service string, sites []site) *TargetInfo {
   242  	numTargets := mathx.Min(4, len(sites))
   243  	targets := make([]v2.Target, numTargets)
   244  	ranks := make(map[string]int)
   245  	var urls []url.URL
   246  
   247  	for i := 0; i < numTargets; i++ {
   248  		// A rate of 6 yields index 0 around 95% of the time, index 1 a little less
   249  		// than 5% of the time, and higher indices infrequently.
   250  		index := mathx.GetExpDistributedInt(6) % len(sites)
   251  		s := sites[index]
   252  		metrics.ServerDistanceRanking.WithLabelValues(strconv.Itoa(i)).Observe(float64(s.rank))
   253  		metrics.MetroDistanceRanking.WithLabelValues(strconv.Itoa(i)).Observe(float64(s.metroRank))
   254  		// TODO(cristinaleon): Once health values range between 0 and 1,
   255  		// pick based on health. For now, pick at random.
   256  		machineIndex := mathx.GetRandomInt(len(s.machines))
   257  		machine := s.machines[machineIndex]
   258  
   259  		r := s.registration
   260  		targets[i] = v2.Target{
   261  			Machine:  machine.name,
   262  			Hostname: machine.host,
   263  			Location: &v2.Location{
   264  				City:    r.City,
   265  				Country: r.CountryCode,
   266  			},
   267  			URLs: make(map[string]string),
   268  		}
   269  		ranks[machine.name] = s.metroRank
   270  
   271  		// Remove the selected site from the set of candidates for the next target selection.
   272  		sites = append(sites[:index], sites[index+1:]...)
   273  
   274  		if urls == nil {
   275  			urls = getURLs(service, r)
   276  		}
   277  	}
   278  
   279  	return &TargetInfo{
   280  		Targets: targets,
   281  		URLs:    urls,
   282  		Ranks:   ranks,
   283  	}
   284  }
   285  
   286  func alwaysPick(opts *NearestOptions) bool {
   287  	// Sites do not need further filtering if the query is already requesting
   288  	// only virtual machines or a specific set of sites or a specific org.
   289  	return opts.Type == "virtual" || len(opts.Sites) > 0 || opts.Org != ""
   290  }
   291  
   292  // pickWithProbability returns true if a pseudo-random number in the interval
   293  // [0.0,1.0) is less than the given site's defined probability.
   294  func pickWithProbability(probability float64) bool {
   295  	return rand.Float64() < probability
   296  }
   297  
   298  // getURLs extracts the URL templates from v2.Registration.Services and outputs
   299  // them as a []url.Url.
   300  func getURLs(service string, registration v2.Registration) []url.URL {
   301  	urls := registration.Services[service]
   302  	result := make([]url.URL, len(urls))
   303  
   304  	for i, u := range urls {
   305  		url, error := url.Parse(u)
   306  		if error != nil {
   307  			continue
   308  		}
   309  		result[i] = *url
   310  	}
   311  
   312  	return result
   313  }
   314  
   315  func biasedDistance(country string, r *v2.Registration, distance float64) float64 {
   316  	// The 'ZZ' country code is used for unknown or unspecified countries.
   317  	if country == "" || country == "ZZ" {
   318  		return distance
   319  	}
   320  
   321  	if country == r.CountryCode {
   322  		return distance
   323  	}
   324  
   325  	return 2 * distance
   326  }