Skip to content

Commit 4072d72

Browse files
committed
perf(api): cache hot author and find-by-book lookups
1 parent 59ff2b2 commit 4072d72

4 files changed

Lines changed: 202 additions & 31 deletions

File tree

server/api/authors.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@ async function getAuthor(
107107
}
108108
}
109109

110+
/** Matches the server-side cache TTL in AuthorService.getAuthorByNameKey. */
111+
const AUTHOR_BY_KEY_MAX_AGE_SECONDS = 180;
112+
110113
async function getAuthorByNameKey(
111114
req: z.infer<typeof GetAuthorByNameKeyValidator>,
112115
res: Response
@@ -115,6 +118,12 @@ async function getAuthorByNameKey(
115118
const authorService = new AuthorService();
116119

117120
const author = await authorService.getAuthorByNameKey(req.params.key, req.query.includeProjects);
121+
122+
// Anonymous, identical for every caller, and hit on nearly every MindTouch library
123+
// page load. A short shared TTL lets browsers and the edge skip the request entirely.
124+
// Applied to the 404 too, so a key with no author does not re-request on every load.
125+
res.setHeader("Cache-Control", `public, max-age=${AUTHOR_BY_KEY_MAX_AGE_SECONDS}`);
126+
118127
if (!author) {
119128
return res.status(404).send({
120129
err: true,

server/api/projects.js

Lines changed: 30 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,23 @@ import {
6060
} from './services/project-search-service.js';
6161
import BookService from './services/book-service.js';
6262
import { conductor500Err } from "../util/errorutils.js";
63+
import { createResponseCache } from '../util/response-cache.js';
6364
const commonsSyncLog = childLogger("commons-sync");
6465

66+
// find-by-book runs on nearly every MindTouch library page load, almost always for a
67+
// bookID we just looked up. Cache the resolved projectID (or null, when the book has no
68+
// project) for a few minutes so those repeats never reach Mongo.
69+
const FIND_BY_BOOK_TTL_SECONDS = 180;
70+
// Each entry is a 10-char projectID or null, so the cap is generous relative to the
71+
// real library catalog and still bounds a client walking well-formed bookIDs that
72+
// match nothing. Past the cap, those lookups just go to Mongo as they did before.
73+
const FIND_BY_BOOK_MAX_KEYS = 20000;
74+
const findByBookCache = createResponseCache({
75+
ttlSeconds: FIND_BY_BOOK_TTL_SECONDS,
76+
maxKeys: FIND_BY_BOOK_MAX_KEYS,
77+
name: "project-find-by-book",
78+
});
79+
6580
const projectListingProjection = {
6681
_id: 0,
6782
orgID: 1,
@@ -634,12 +649,21 @@ async function findByBook(req, res) {
634649

635650
const [library, pageID] = split;
636651

637-
const project = await Project.findOne({
638-
libreLibrary: library,
639-
libreCoverID: pageID,
640-
}).lean();
652+
const projectID = await findByBookCache.getOrLoad(`${library}-${pageID}`, async () => {
653+
const project = await Project.findOne({
654+
libreLibrary: { $eq: library },
655+
libreCoverID: { $eq: pageID },
656+
}).select('projectID').lean();
657+
658+
return project?.projectID ?? null;
659+
});
660+
661+
// Anonymous and identical for every caller, so browsers and the edge can hold it too.
662+
// Set on the 404 as well: most library pages have no Conductor project, and those are
663+
// exactly the requests we do not want repeated on every load.
664+
res.setHeader("Cache-Control", `public, max-age=${FIND_BY_BOOK_TTL_SECONDS}`);
641665

642-
if(!project){
666+
if(!projectID){
643667
return res.status(404).send({
644668
err: true,
645669
errMsg: conductorErrors.err11,
@@ -648,7 +672,7 @@ async function findByBook(req, res) {
648672

649673
return res.send({
650674
err: false,
651-
projectID: project.projectID,
675+
projectID,
652676
});
653677
} catch (err) {
654678
logger.error({ err }, "findByBook failed");

server/api/services/author-service.ts

Lines changed: 45 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { BaseConductorInfiniteScrollResponse } from "../../types";
44
import Author, { AuthorInterface } from "../../models/author.js";
55
import { escapeRegEx, getPaginationOffset } from "../../util/helpers.js";
66
import { Types } from "mongoose";
7+
import { createResponseCache } from "../../util/response-cache.js";
78

89
export default class AuthorService {
910
public async getAuthors(params: z.infer<typeof GetAuthorsValidator>['query']): Promise<BaseConductorInfiniteScrollResponse<AuthorInterface>> {
@@ -53,34 +54,53 @@ export default class AuthorService {
5354
return aggRes.length > 0 ? aggRes[0] : null;
5455
}
5556

57+
// This lookup is hit on nearly every MindTouch library page load, almost always
58+
// for the same handful of keys. Static so the cache is shared across requests:
59+
// the service itself is constructed per request.
60+
private static readonly BY_NAME_KEY_CACHE_TTL_SECONDS = 180;
61+
// Entries can carry an author's project list, so this is capped tighter than a
62+
// cache of scalars would be. It still comfortably exceeds the number of distinct
63+
// authors a library serves in a 3 minute window.
64+
private static readonly BY_NAME_KEY_CACHE_MAX_KEYS = 2000;
65+
private static readonly _byNameKeyCache = createResponseCache<AuthorInterface | null>({
66+
ttlSeconds: AuthorService.BY_NAME_KEY_CACHE_TTL_SECONDS,
67+
maxKeys: AuthorService.BY_NAME_KEY_CACHE_MAX_KEYS,
68+
name: "author-by-name-key",
69+
});
70+
5671
public async getAuthorByNameKey(nameKey: string, includeProjects = false): Promise<AuthorInterface | null> {
57-
const aggRes = await Author.aggregate([
58-
{
59-
$match: {
60-
nameKey: nameKey,
61-
orgID: process.env.ORG_ID,
72+
// `includeProjects` changes both the $lookup and the projection, so it is part of the key.
73+
const cacheKey = `${process.env.ORG_ID}:${nameKey}:${includeProjects ? 1 : 0}`;
74+
75+
return AuthorService._byNameKeyCache.getOrLoad(cacheKey, async () => {
76+
const aggRes = await Author.aggregate([
77+
{
78+
$match: {
79+
nameKey: nameKey,
80+
orgID: process.env.ORG_ID,
81+
},
6282
},
63-
},
64-
...(includeProjects ? [AuthorService.LOOKUP_AUTHOR_PROJECTS_STAGE] : []),
65-
{
66-
$project: {
67-
_id: 0,
68-
nameKey: 1,
69-
name: 1,
70-
nameURL: 1,
71-
campusName: 1,
72-
campusURL: 1,
73-
pictureURL: 1,
74-
pictureCircle: 1,
75-
attributionPrefix: 1,
76-
programName: 1,
77-
programURL: 1,
78-
...(includeProjects ? { projects: 1 } : {}),
83+
...(includeProjects ? [AuthorService.LOOKUP_AUTHOR_PROJECTS_STAGE] : []),
84+
{
85+
$project: {
86+
_id: 0,
87+
nameKey: 1,
88+
name: 1,
89+
nameURL: 1,
90+
campusName: 1,
91+
campusURL: 1,
92+
pictureURL: 1,
93+
pictureCircle: 1,
94+
attributionPrefix: 1,
95+
programName: 1,
96+
programURL: 1,
97+
...(includeProjects ? { projects: 1 } : {}),
98+
}
7999
}
80-
}
81-
]);
82-
83-
return aggRes.length > 0 ? aggRes[0] : null;
100+
]);
101+
102+
return aggRes.length > 0 ? aggRes[0] : null;
103+
});
84104
}
85105

86106
public async createAuthor(data: z.infer<typeof CreateAuthorValidator>['body']): Promise<AuthorInterface> {

server/util/response-cache.ts

Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import NodeCache from "node-cache";
2+
import { childLogger } from "../logger.js";
3+
4+
const logger = childLogger("response-cache");
5+
6+
export interface ResponseCache<T> {
7+
getOrLoad(key: string, loader: () => Promise<T>): Promise<T>;
8+
/** Drops a single key. Mostly useful from a script or a future write path. */
9+
invalidate(key: string): void;
10+
}
11+
12+
export interface ResponseCacheOptions {
13+
/** How long a loaded value stays fresh. */
14+
ttlSeconds: number;
15+
/**
16+
* Hard ceiling on stored entries. Required, not defaulted: these caches sit
17+
* in front of public endpoints and store misses, so the caller has to state
18+
* how much memory the endpoint is allowed to hold.
19+
*/
20+
maxKeys: number;
21+
/** Identifies the cache in logs. */
22+
name: string;
23+
}
24+
25+
/**
26+
* A short-lived, in-process read cache for hot endpoints whose backing data
27+
* rarely changes.
28+
*
29+
* Two things happen here. Resolved values (including `null`, so misses are
30+
* cached too) are held for `ttlSeconds`, and concurrent loads of the same key
31+
* collapse into a single in-flight promise, so a burst of requests arriving on
32+
* a cold key costs one round trip instead of one per request.
33+
*
34+
* Rejections are never cached: the key is left empty and the next caller
35+
* retries.
36+
*
37+
* This lives in the process, not in Mongo or Redis. Every container holds its
38+
* own copy, so a write made elsewhere becomes visible only once the TTL lapses.
39+
* Only cache data where that staleness window is acceptable.
40+
*
41+
* Storage is capped at `maxKeys`. Callers are public endpoints that cache
42+
* misses, so a client walking valid-but-nonexistent keys would otherwise grow
43+
* the heap unchecked until the TTL swept it. At the cap we stop admitting new
44+
* keys and serve those loads straight from the loader, rather than evicting to
45+
* make room: refusing admission keeps the genuinely hot entries resident, while
46+
* LRU or FIFO eviction would let a flood of one-shot keys push them out. The
47+
* effect of a flood is that the cache stops helping for unseen keys, never that
48+
* the process runs out of memory.
49+
*
50+
* `useClones: false` means callers share the stored object. Treat anything
51+
* returned as read-only.
52+
*/
53+
export function createResponseCache<T>(
54+
opts: ResponseCacheOptions
55+
): ResponseCache<T> {
56+
const cache = new NodeCache({
57+
stdTTL: opts.ttlSeconds,
58+
checkperiod: opts.ttlSeconds,
59+
useClones: false,
60+
maxKeys: opts.maxKeys,
61+
});
62+
const inFlight = new Map<string, Promise<T>>();
63+
64+
// node-cache throws ECACHEFULL from `set` once maxKeys is reached. That is a
65+
// normal, self-healing state (the next checkperiod sweep frees slots), so it
66+
// must never fail the request. Log it at most once per TTL window: a flood
67+
// would otherwise emit a line per request, which is its own resource problem.
68+
let lastFullWarnAt = 0;
69+
70+
const admit = (key: string, value: T) => {
71+
try {
72+
cache.set(key, value);
73+
} catch (err) {
74+
const now = Date.now();
75+
if (now - lastFullWarnAt >= opts.ttlSeconds * 1000) {
76+
lastFullWarnAt = now;
77+
logger.warn(
78+
{ err, cache: opts.name, maxKeys: opts.maxKeys, keys: cache.keys().length },
79+
"Response cache is full; new keys are being served uncached"
80+
);
81+
}
82+
}
83+
};
84+
85+
return {
86+
async getOrLoad(key: string, loader: () => Promise<T>): Promise<T> {
87+
// `has` rather than a truthiness check on `get`, so a cached `null` is a
88+
// hit instead of falling through to the loader on every request.
89+
if (cache.has(key)) {
90+
logger.debug({ cache: opts.name, key }, "Cache hit");
91+
return cache.get<T>(key) as T;
92+
}
93+
94+
const existing = inFlight.get(key);
95+
if (existing) {
96+
return existing;
97+
}
98+
99+
const pending = (async () => {
100+
const value = await loader();
101+
admit(key, value);
102+
return value;
103+
})();
104+
105+
inFlight.set(key, pending);
106+
107+
try {
108+
return await pending;
109+
} finally {
110+
inFlight.delete(key);
111+
}
112+
},
113+
114+
invalidate(key: string) {
115+
cache.del(key);
116+
},
117+
};
118+
}

0 commit comments

Comments
 (0)