diff --git a/index.js b/index.js index e76af45..2d12385 100644 --- a/index.js +++ b/index.js @@ -100,6 +100,50 @@ function getBotProfile(appName) { return botProfiles[appName] || null; } +// Cached list of every Webex group in the org, used by the "Additional groups" +// dropdown on the compose page. findWebexGroup() paginates through ~10k groups +// 500 at a time, which used to run on every /user/groups/find request; we now +// refresh once at startup and once a day and serve everything else from +// memory. `refreshing` is a shared in-flight Promise so a concurrent refresh +// (e.g. the cron firing during a slow startup fetch) can't stampede. +var groupsCache = { + data: null, + lastRefreshed: null, + refreshing: null, +}; + +function refreshGroupsCache() { + if (groupsCache.refreshing) return groupsCache.refreshing; + var startedAt = Date.now(); + logger('groupsCache', 'Refreshing group list from Webex...'); + groupsCache.refreshing = findWebexGroup() + .then(function (groups) { + groupsCache.data = groups; + groupsCache.lastRefreshed = new Date(); + logger('groupsCache', + 'Cached ' + groups.length + ' groups in ' + + (Date.now() - startedAt) + 'ms.'); + return groups; + }) + .catch(function (err) { + logger('groupsCache', 'Refresh failed: ' + (err && err.message || err)); + throw err; + }) + .finally(function () { + groupsCache.refreshing = null; + }); + return groupsCache.refreshing; +} + +// Returns the cached groups if we have any; otherwise waits for whatever +// refresh is currently in flight (or kicks one off). Requests only ever block +// on the very first call after startup — every subsequent call resolves +// synchronously from memory. +function getGroupsFromCache() { + if (groupsCache.data) return Promise.resolve(groupsCache.data); + return refreshGroupsCache(); +} + // Express middleware: 404s any /CollabCentral/:app/* request whose `:app` is // not a known + enabled bot. Attaches the bot config to req.botConfig for // downstream handlers. @@ -180,6 +224,10 @@ var server = app.listen(serverPort, function () { loadBotProfiles() .then(() => logger("startup", "Loaded " + Object.keys(botProfiles).length + " bot profile(s).")) .catch(err => logger("startup", "loadBotProfiles error: " + (err && err.message || err))); + // Warm the groups cache in the background so the very first user hitting + // /user/groups/find gets an instant response. Failures are non-fatal: + // getGroupsFromCache() will simply try again on the first request. + refreshGroupsCache().catch(function () { /* logged inside */ }); }); server.setTimeout(3000000); const upload = multer({ limits: { fileSize: 4000000 } }).fields( @@ -222,6 +270,12 @@ cron.schedule('0 * * * * *', () => { .catch(error => logger("checkScheduledJobs", error)); }, { timezone: cronTimezone }) +// Daily at 03:00 local time: rebuild the org-wide groups cache. Offset from +// the 01:10 jobs cleanup so the two crons don't fight over the event loop. +cron.schedule('0 0 3 * * *', function () { + refreshGroupsCache().catch(function () { /* logged inside */ }); +}, { timezone: cronTimezone }) + //Routes to be used // Validate the :app segment for every /CollabCentral/:app/* request. This // runs before the static mount and all the per-route handlers below, so any @@ -401,15 +455,13 @@ app.get('/CollabCentral/:app/user/:scope/:action', (req, res) => { res.status(200).send(config.webex.bot[req.params.app].authorized[req.cookies.id].groups) } else if (req.params.action == "find") { logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find"); - - findWebexGroup() - .then(response => { - //console.log(response) - saveConfig(response, "./testData.json") - - res.status(200).send(response); - }) - + getGroupsFromCache() + .then(function (groups) { res.status(200).send(groups); }) + .catch(function (err) { + logger('apiEndpoint(' + req.params.app + ')', + 'groups/find failed: ' + (err && err.message || err)); + res.status(502).send('Failed to load groups from Webex.'); + }); } } else { res.status(404) } } else { res.status(401) } @@ -673,38 +725,32 @@ function findWebexGroup() { 'Content-Type': `application/json` } }; - console.log(url) while (groupSize > groups.length) { - console.log("groupSize: " + groupSize) - console.log("groups.length: " + groups.length) try { - console.log(url); - var fetchedGroups = await fetchWithRateLimit(url, requestOptions) + var fetchedGroups = await fetchWithRateLimit(url, requestOptions); if (fetchedGroups.ok) { var groupData = await fetchedGroups.json(); - console.log("Total Results: " + groupData.totalResults) groupSize = groupData.totalResults; for (var group of groupData.groups) { groups.push(group); } startIndex = startIndex + count; url.searchParams.delete("startIndex"); - url.searchParams.append("startIndex", startIndex) + url.searchParams.append("startIndex", startIndex); } else { - memberSize = 0; - reject(fetchedGroups.status + ": " + fetchedGroups.statusText); + return reject(fetchedGroups.status + ": " + fetchedGroups.statusText); } } catch (error) { - console.log("Error findGroupById: " + error); + logger('findWebexGroup', 'page fetch failed: ' + (error && error.message || error)); + return reject(error); } } - console.log("Groups found: " + groups.length); resolve(groups); }) } function whoAmI(bearerToken) { return new Promise(async function (resolve, reject) { - logger("whoAmI", "Person is having an existential crisis of identify.") + logger("whoAmI", "Person is having an existential crisis of identity.") var myHeaders = { "Authorization": "Bearer " + bearerToken, "Content-Type": "application/json"