Skip to content

Commit 1b5be14

Browse files
feat: load balance test location in WPT (DELO-4766) (#95)
This feature introduces load-balancing across multiple WPT locations. The goal is to always choose least loaded region. Please note that US-based regions are taken into consideration only. The following configuration options are introduced: * `WTP_LS_CACHE_TTL` (defaults: `10` sec) - how long to cache locations and selected location; `0` means we check best location on every test run * `WTP_LS_DEFAULT_LOCATION` (defaults: `IAD_US_01` - which location to use in case there is a problem with API and there is nothing selected yet * `WTP_LS_UPDATE_TIMEOUT` - (defaults: `20` sec) timeout for updating locations Also, for better insight there are two new endpoints (both responses served from local cache): * `/locations` - prints out all locations we consider along with their metrics * `/locations/current` - identifier of the location we currently use as the best one
1 parent 01447ed commit 1b5be14

File tree

8 files changed

+299
-105
lines changed

8 files changed

+299
-105
lines changed

app.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ wpt(app);
3232

3333
// catch 404 and forward to error handler
3434
app.all('*', function (req, res) {
35-
res.send('what???', 404);
35+
res.status(404).send('what???');
3636
});
3737

3838
app.use(function (req, res, next) {

config/default.js

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,12 @@ const conf = {
3535
"lcp": process.env.LCP_PATH || "data.median.firstView.largestPaints",
3636
"lcpURL": process.env.LCP_URL_PATH || "data.median.firstView.LargestContentfulPaintImageURL"
3737
},
38-
timeout: process.env.WTP_TIMEOUT || 30000
38+
timeout: process.env.WTP_TIMEOUT || 30000,
39+
"locationSelector": {
40+
"cacheTtl": process.env.WTP_LS_CACHE_TTL || 10,
41+
"updateTimeout": process.env.WTP_LS_UPDATE_TIMEOUT || 20,
42+
"defaultLocation": process.env.WTP_LS_DEFAULT_LOCATION || "IAD_US_01"
43+
}
3944
},
4045
"cloudinary": {
4146
"cloudName": process.env.CLOUDINARY_NAME,

package.json

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,16 @@
99
},
1010
"dependencies": {
1111
"async": "^3.2.6",
12+
"async-mutex": "^0.5.0",
1213
"body-parser": "~1.20.3",
1314
"bytes": "^3.1.2",
1415
"cloudinary": "1.41.3",
1516
"config": "^3.3.12",
1617
"cookie-parser": "~1.4.7",
17-
"debug": "~4.3.7",
18-
"dotenv": "^16.4.5",
19-
"express": "~4.21.1",
20-
"got": "^14.4.3",
18+
"debug": "~4.4.0",
19+
"dotenv": "^16.4.7",
20+
"express": "~4.21.2",
21+
"got": "^14.4.5",
2122
"lodash": "^4.17.21",
2223
"rollbar": "^2.26.4",
2324
"valid-url": "^1.0.9"
@@ -26,8 +27,8 @@
2627
"chai": "^4.5.0",
2728
"chai-http": "^4.4.0",
2829
"husky": "^8.0.3",
29-
"mocha": "^10.7.3",
30-
"nock": "^13.5.5",
30+
"mocha": "^10.8.2",
31+
"nock": "^13.5.6",
3132
"patch-package": "^8.0.0",
3233
"sinon": "^16.1.3",
3334
"sinon-chai": "^3.7.0"

routes/wpt.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
const express = require('express');
33
const validUrl = require('valid-url');
44
const apiCaller = require('../wtp/apiCaller');
5+
const locationSelector = require("../wtp/locationSelector");
56
const logger = require('../logger').logger;
67
const {LOG_LEVEL_INFO, LOG_LEVEL_WARNING, LOG_LEVEL_ERROR, LOG_LEVEL_CRITICAL, LOG_LEVEL_DEBUG} = require('../logger');
78
const path = require('path');
@@ -81,7 +82,19 @@ const wtp = (app) => {
8182
app.get('/version', (req, res) => {
8283
const packageJson = require('../package.json');
8384
res.json({version: packageJson.version});
85+
});
86+
87+
app.get('/locations', async (req, res) => {
88+
let locations = locationSelector.cachedAllLocations;
89+
res.json({locations});
8490
})
91+
92+
app.get('/locations/current', async (req, res) => {
93+
let location = locationSelector.location;
94+
let lastUpdated = new Date(locationSelector.lastUpdated || 0).toISOString();
95+
res.json({location, lastUpdated});
96+
})
97+
8598
};
8699

87100
module.exports = wtp;

wtp/apiCaller.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44

55
"use strict";
66

7-
87
const path = require('path');
98
const got = (...args) => import('got').then(({default: got}) => got(...args));
109
const config = require('config');
@@ -16,7 +15,8 @@ const {truncateString} = require('../util/strings');
1615
const RESULTS_URL = 'https://www.webpagetest.org/jsonResult.php';
1716
const RUN_TEST_URL = 'http://www.webpagetest.org/runtest.php';
1817
const GET_TEST_STATUS = 'http://www.webpagetest.org/testStatus.php';
19-
18+
const locationSelector = require('./locationSelector');
19+
const apiKeys = require('./apiKey');
2020

2121
const getTestResults = async (testId, quality, cb) => {
2222
let options = {
@@ -58,11 +58,8 @@ const getTestResults = async (testId, quality, cb) => {
5858
}
5959
};
6060

61-
6261
const runWtpTest = async (url, mobile, cb) => {
6362
//logger.info('Running new test ' + url);
64-
const apiKeys = config.get('wtp.apiKey').split(',');
65-
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
6663
let options = {
6764
method: "POST",
6865
url: RUN_TEST_URL,
@@ -72,12 +69,12 @@ const runWtpTest = async (url, mobile, cb) => {
7269
width: config.get('wtp.viewportWidth'),
7370
height: config.get('wtp.viewportHeight'),
7471
custom: config.get('wtp.imageScript'),
75-
location: 'Dulles:Chrome.Native', // Native means no speed shaping in browser, full speed ahead
76-
mobile: (mobile) ? 1 : 0,
72+
location: await locationSelector.getLocation() + ':Chrome.Native', // Native means no speed shaping in browser, full speed ahead
73+
mobile: (mobile) ? 1 : 0,
7774
fvonly: 1, // first view only
7875
timeline: 1 // workaround for WPT sometimes hanging on getComputedStyle()
7976
},
80-
headers: { 'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKey },
77+
headers: { 'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKeys.getRandom() },
8178
throwHttpErrors: false
8279
};
8380
let response;

wtp/apiKey.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
"use strict";
2+
3+
const config = require('config');
4+
5+
function getRandom() {
6+
const apiKeys = config.get('wtp.apiKey').split(',');
7+
const apiKey = apiKeys[Math.floor(Math.random() * apiKeys.length)];
8+
return apiKey;
9+
}
10+
11+
module.exports = {
12+
getRandom: getRandom
13+
};

wtp/locationSelector.js

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
const got = (...args) => import('got').then(({default: got}) => got(...args));
2+
const config = require('config');
3+
const {Mutex, withTimeout, E_TIMEOUT} = require('async-mutex');
4+
const apiKeys = require('./apiKey');
5+
const path = require("path");
6+
const logger = require('../logger').logger;
7+
8+
const GET_LOCATIONS = 'http://www.webpagetest.org/getLocations.php?f=json';
9+
10+
class LocationSelector {
11+
constructor() {
12+
if (!LocationSelector.instance) {
13+
this.cachedAllLocations = [];
14+
this.location = config.get('wtp.locationSelector.defaultLocation');
15+
this.lastUpdated = null;
16+
this.mutex = withTimeout(new Mutex(), config.get('wtp.locationSelector.updateTimeout') * 1000);
17+
LocationSelector.instance = this;
18+
}
19+
return LocationSelector.instance;
20+
}
21+
22+
isExpired() {
23+
const now = Date.now();
24+
return (!this.lastUpdated || (now - this.lastUpdated) > config.get('wtp.locationSelector.cacheTtl') * 1000);
25+
}
26+
27+
async fetchLocations() {
28+
let options = {
29+
method: "GET",
30+
url: GET_LOCATIONS,
31+
headers: {'User-Agent': 'WebSpeedTest', 'X-WPT-API-KEY': apiKeys.getRandom()},
32+
};
33+
let response;
34+
let rollBarMsg = {};
35+
try {
36+
response = await got(options);
37+
const {statusCode, body} = response;
38+
let bodyJson = JSON.parse(body);
39+
rollBarMsg = {thirdPartyErrorCode: response.statusCode, file: path.basename((__filename))};
40+
if (statusCode !== 200) {
41+
rollBarMsg.thirdPartyErrorBody = bodyJson;
42+
logger.error('WPT returned bad status', rollBarMsg);
43+
return;
44+
}
45+
return bodyJson.data;
46+
} catch (error) {
47+
logger.critical('Error fetching WTP locations', JSON.stringify(error, Object.getOwnPropertyNames(error)));
48+
}
49+
};
50+
51+
getLocationScore(loc) {
52+
// no instances running, hopefully they will be spin up for our request?
53+
if (this.getLocationCapacity(loc) == 0) {
54+
return 1;
55+
}
56+
57+
let metrics = loc.PendingTests;
58+
return (metrics.HighPriority + metrics.Testing) / (metrics.Idle + metrics.Testing)
59+
}
60+
61+
getLocationCapacity(loc) {
62+
return loc.PendingTests.Idle + loc.PendingTests.Testing;
63+
}
64+
65+
getBestLocationId(locations) {
66+
let selected = locations.reduce((acc, cur) => {
67+
// if nothing to compare to, use current value
68+
if (!acc) {
69+
return cur;
70+
}
71+
72+
// if acc less loaded
73+
if (acc.score < cur.score) {
74+
return acc;
75+
}
76+
77+
// if cur less loaded
78+
if (acc.score > cur.score) {
79+
return cur;
80+
}
81+
82+
// if same load on acc and cur
83+
// then choose the one with bigger capacity (Idle + Testing)
84+
return this.getLocationCapacity(acc) > this.getLocationCapacity(cur) ? acc : cur;
85+
});
86+
87+
return selected.location;
88+
}
89+
90+
async updateLocations() {
91+
const newLocations = await this.fetchLocations();
92+
if (!newLocations) {
93+
return
94+
}
95+
96+
const filtered = Object.keys(newLocations)
97+
.filter(key => key.includes("_US_")) // we only want US-based instances
98+
.reduce((arr, key) => {
99+
return [...arr, newLocations[key]];
100+
}, []);
101+
102+
if (filtered.length === 0) {
103+
return
104+
}
105+
106+
// enrich locations with our internal score
107+
filtered.forEach((loc) => {
108+
loc.score = this.getLocationScore(loc);
109+
});
110+
111+
this.location = this.getBestLocationId(filtered);
112+
this.cachedAllLocations = filtered;
113+
this.lastUpdated = Date.now();
114+
};
115+
116+
async getLocation() {
117+
if (this.isExpired()) {
118+
try {
119+
await this.mutex.runExclusive(async () => {
120+
if (this.isExpired()) {
121+
await this.updateLocations();
122+
}
123+
});
124+
} catch (e) {
125+
if (e === E_TIMEOUT) {
126+
logger.error('Locations update is taking too long', e);
127+
}
128+
}
129+
}
130+
131+
return this.location;
132+
}
133+
}
134+
135+
const instance = new LocationSelector();
136+
module.exports = instance;

0 commit comments

Comments
 (0)