From 452673b9befee8ca8cd89d5db64457c5e811e0a9 Mon Sep 17 00:00:00 2001 From: "Joseph B. McQueen" Date: Wed, 1 Jul 2026 20:13:58 -0400 Subject: [PATCH] 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 --- index.js | 88 ++++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 67 insertions(+), 21 deletions(-) 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"