Cache Webex group list in-process + fix log typo

The /user/groups/find endpoint used to hit Webex on every request,
paginating through ~10k groups 500 at a time (roughly 20 sequential
round trips per compose-page load). Now the whole list lives in a
process-local cache that refreshes on startup and once a day at 03:00
CRON_TIMEZONE.

- New groupsCache = { data, lastRefreshed, refreshing }. refreshing is
  a shared in-flight Promise so a startup refresh and the daily cron
  can't stampede if their timing overlaps.
- refreshGroupsCache() wraps findWebexGroup() with load timing +
  error logging.
- getGroupsFromCache() returns the cached list immediately; only the
  very first request after startup waits (and only if the startup
  refresh hasn't completed yet).
- Warm the cache in the background right after loadBotProfiles() in
  the app.listen callback.
- Third cron ('0 0 3 * * *') refreshes the cache daily, offset from
  the 01:10 jobs cleanup and per-minute token/scheduled-jobs tick so
  they don't fight for the event loop.

Bug fixes rolled in while I was in findWebexGroup:
- Return reject(...) on a non-ok Webex response instead of also
  continuing the while loop, which used to race resolve/reject.
- Return reject(error) from the try/catch so a Webex hiccup no longer
  hangs the paginator forever; previously it caught+logged and let
  the loop spin.
- Drop the stray `memberSize = 0` assignment (memberSize was never
  declared in scope).
- Drop the per-page console.log noise; failures now go through the
  timestamped logger under the findWebexGroup tag.

Route cleanup:
- /user/groups/find now serves getGroupsFromCache() and returns 502
  with a logged reason if the cache is empty AND the refresh failed.
- Dropped the stray saveConfig(response, "./testData.json") that
  used to persist the entire fetched group list to a scratch file on
  every request.

Also: fix "identify" -> "identity" in the whoAmI startup log line.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Joseph B. McQueen 2026-07-01 20:13:58 -04:00
parent 04fb4f6442
commit 452673b9be

View file

@ -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"