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 }