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:
parent
04fb4f6442
commit
452673b9be
1 changed files with 67 additions and 21 deletions
88
index.js
88
index.js
|
|
@ -100,6 +100,50 @@ function getBotProfile(appName) {
|
||||||
return botProfiles[appName] || null;
|
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
|
// Express middleware: 404s any /CollabCentral/:app/* request whose `:app` is
|
||||||
// not a known + enabled bot. Attaches the bot config to req.botConfig for
|
// not a known + enabled bot. Attaches the bot config to req.botConfig for
|
||||||
// downstream handlers.
|
// downstream handlers.
|
||||||
|
|
@ -180,6 +224,10 @@ var server = app.listen(serverPort, function () {
|
||||||
loadBotProfiles()
|
loadBotProfiles()
|
||||||
.then(() => logger("startup", "Loaded " + Object.keys(botProfiles).length + " bot profile(s)."))
|
.then(() => logger("startup", "Loaded " + Object.keys(botProfiles).length + " bot profile(s)."))
|
||||||
.catch(err => logger("startup", "loadBotProfiles error: " + (err && err.message || err)));
|
.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);
|
server.setTimeout(3000000);
|
||||||
const upload = multer({ limits: { fileSize: 4000000 } }).fields(
|
const upload = multer({ limits: { fileSize: 4000000 } }).fields(
|
||||||
|
|
@ -222,6 +270,12 @@ cron.schedule('0 * * * * *', () => {
|
||||||
.catch(error => logger("checkScheduledJobs", error));
|
.catch(error => logger("checkScheduledJobs", error));
|
||||||
}, { timezone: cronTimezone })
|
}, { 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
|
//Routes to be used
|
||||||
// Validate the :app segment for every /CollabCentral/:app/* request. This
|
// Validate the :app segment for every /CollabCentral/:app/* request. This
|
||||||
// runs before the static mount and all the per-route handlers below, so any
|
// 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)
|
res.status(200).send(config.webex.bot[req.params.app].authorized[req.cookies.id].groups)
|
||||||
} else if (req.params.action == "find") {
|
} else if (req.params.action == "find") {
|
||||||
logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find");
|
logger("apiEndpoint(" + req.params.app + ")", "GET /user/groups/find");
|
||||||
|
getGroupsFromCache()
|
||||||
findWebexGroup()
|
.then(function (groups) { res.status(200).send(groups); })
|
||||||
.then(response => {
|
.catch(function (err) {
|
||||||
//console.log(response)
|
logger('apiEndpoint(' + req.params.app + ')',
|
||||||
saveConfig(response, "./testData.json")
|
'groups/find failed: ' + (err && err.message || err));
|
||||||
|
res.status(502).send('Failed to load groups from Webex.');
|
||||||
res.status(200).send(response);
|
});
|
||||||
})
|
|
||||||
|
|
||||||
}
|
}
|
||||||
} else { res.status(404) }
|
} else { res.status(404) }
|
||||||
} else { res.status(401) }
|
} else { res.status(401) }
|
||||||
|
|
@ -673,38 +725,32 @@ function findWebexGroup() {
|
||||||
'Content-Type': `application/json`
|
'Content-Type': `application/json`
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
console.log(url)
|
|
||||||
while (groupSize > groups.length) {
|
while (groupSize > groups.length) {
|
||||||
console.log("groupSize: " + groupSize)
|
|
||||||
console.log("groups.length: " + groups.length)
|
|
||||||
try {
|
try {
|
||||||
console.log(url);
|
var fetchedGroups = await fetchWithRateLimit(url, requestOptions);
|
||||||
var fetchedGroups = await fetchWithRateLimit(url, requestOptions)
|
|
||||||
if (fetchedGroups.ok) {
|
if (fetchedGroups.ok) {
|
||||||
var groupData = await fetchedGroups.json();
|
var groupData = await fetchedGroups.json();
|
||||||
console.log("Total Results: " + groupData.totalResults)
|
|
||||||
groupSize = groupData.totalResults;
|
groupSize = groupData.totalResults;
|
||||||
for (var group of groupData.groups) {
|
for (var group of groupData.groups) {
|
||||||
groups.push(group);
|
groups.push(group);
|
||||||
}
|
}
|
||||||
startIndex = startIndex + count;
|
startIndex = startIndex + count;
|
||||||
url.searchParams.delete("startIndex");
|
url.searchParams.delete("startIndex");
|
||||||
url.searchParams.append("startIndex", startIndex)
|
url.searchParams.append("startIndex", startIndex);
|
||||||
} else {
|
} else {
|
||||||
memberSize = 0;
|
return reject(fetchedGroups.status + ": " + fetchedGroups.statusText);
|
||||||
reject(fetchedGroups.status + ": " + fetchedGroups.statusText);
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} 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);
|
resolve(groups);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
function whoAmI(bearerToken) {
|
function whoAmI(bearerToken) {
|
||||||
return new Promise(async function (resolve, reject) {
|
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 = {
|
var myHeaders = {
|
||||||
"Authorization": "Bearer " + bearerToken,
|
"Authorization": "Bearer " + bearerToken,
|
||||||
"Content-Type": "application/json"
|
"Content-Type": "application/json"
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue