From 05a5aabb452602f247acac5c44c5b6ef26715a3a Mon Sep 17 00:00:00 2001 From: "Christian Morford-Waite [SSW]" Date: Tue, 28 Jan 2025 17:50:16 +1100 Subject: [PATCH] #3546 - Added event api endpoint caching (#3549) * Added event api endpoint caching Using npm stale-while-revalidate-cache package * Added cache hardening - Using `lru-cache` to limit memory growth - Limiting cache key combinations to avoid key polluting - Max length of presenter to avoid large key sizes - LRU cache expires after 1 hour --- package.json | 10 +++-- pnpm-lock.yaml | 26 ++++++++++++ services/server/getEvents.ts | 81 ++++++++++++++++++++++++++++++++---- 3 files changed, 106 insertions(+), 11 deletions(-) diff --git a/package.json b/package.json index 3e1490f0f0..f45f16736f 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ }, "devDependencies": { "@jest/globals": "^29.7.0", + "@next/eslint-plugin-next": "14.2.15", "@playwright/test": "^1.48.2", "@svgr/webpack": "^8.1.0", "@testing-library/jest-dom": "^6.4.6", @@ -33,22 +34,21 @@ "@types/react": "^18.3.12", "@typescript-eslint/eslint-plugin": "^8.18.0", "@typescript-eslint/parser": "^8.18.2", - "@next/eslint-plugin-next": "14.2.15", "autoprefixer": "^10.4.20", "eslint": "^8.57.1", "eslint-config-next": "14.2.15", - "eslint-plugin-tailwindcss": "^3.17.5", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-tailwindcss": "^3.17.5", "jest": "^29.7.0", "jest-environment-jsdom": "^29.7.0", "next-sitemap": "^4.2.3", "playwright": "^1.48.2", "postcss": "^8.5.1", - "prettier": "^3.4.2", - "prettier-plugin-tailwindcss": "^0.6.9", "postcss-import": "^16.1.0", "postcss-nesting": "^13.0.1", + "prettier": "^3.4.2", + "prettier-plugin-tailwindcss": "^0.6.9", "source-map-explorer": "^2.5.3", "ts-node": "^10.9.2" }, @@ -82,6 +82,7 @@ "formik": "^2.4.6", "framer-motion": "^11.12.0", "lodash": "^4.17.21", + "lru-cache": "^11.0.2", "lucide-react": "^0.460.0", "next": "^14.2.15", "next-plugin-preval": "^1.2.6", @@ -101,6 +102,7 @@ "schema-dts": "^1.1.2", "sharp": "^0.33.5", "ssw.megamenu": "4.9.4", + "stale-while-revalidate-cache": "^3.4.0", "tailwind-merge": "^2.5.4", "tailwindcss": "^3.4.14", "tailwindcss-gradients": "^3.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cbc6790cce..a4ba0b5b0a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -99,6 +99,9 @@ importers: lodash: specifier: ^4.17.21 version: 4.17.21 + lru-cache: + specifier: ^11.0.2 + version: 11.0.2 lucide-react: specifier: ^0.460.0 version: 0.460.0(react@18.3.1) @@ -156,6 +159,9 @@ importers: ssw.megamenu: specifier: 4.9.4 version: 4.9.4(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + stale-while-revalidate-cache: + specifier: ^3.4.0 + version: 3.4.0 tailwind-merge: specifier: ^2.5.4 version: 2.5.4 @@ -4953,6 +4959,10 @@ packages: resolution: {integrity: sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==} engines: {node: '>=12'} + emittery@0.9.2: + resolution: {integrity: sha512-sweWHu3j4dQm+NjLPu17pv+m5lCeK7g4Ov0NgfbRUEyzLc59DYDeRYXqlxEvuolaToI0VR3ThjFAghzl7Acjfw==} + engines: {node: '>=12'} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -6447,6 +6457,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.0.2: + resolution: {integrity: sha512-123qHRfJBmo2jXDbo/a5YOQrJoHF/GNQTLzQ5+IdK5pWpceK17yRc6ozlWd25FxvGKQbIUs91fDFkXmDHTKcyA==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -8103,6 +8117,10 @@ packages: resolution: {integrity: sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==} engines: {node: '>=10'} + stale-while-revalidate-cache@3.4.0: + resolution: {integrity: sha512-v6I3mtR/34Ib48qGUemNrl905QVGPnbO/cvpFEGYqCRmJwlMBr3lK6utDuABGZCe96SrVs13sYa9zsjepGD3cw==} + engines: {node: '>=14'} + state-local@1.0.7: resolution: {integrity: sha512-HTEHMNieakEnoe33shBYcZ7NX83ACUjCu8c40iOGEZsngj9zRnkqS9j1pqQPXwobB0ZcVTk27REb7COQ0UR59w==} @@ -14908,6 +14926,8 @@ snapshots: emittery@0.13.1: {} + emittery@0.9.2: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -16838,6 +16858,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.0.2: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -18876,6 +18898,10 @@ snapshots: dependencies: escape-string-regexp: 2.0.0 + stale-while-revalidate-cache@3.4.0: + dependencies: + emittery: 0.9.2 + state-local@1.0.7: {} statuses@2.0.1: {} diff --git a/services/server/getEvents.ts b/services/server/getEvents.ts index 58a241d723..88977d0c97 100644 --- a/services/server/getEvents.ts +++ b/services/server/getEvents.ts @@ -1,17 +1,79 @@ import client from "@/tina/client"; +import { LRUCache } from "lru-cache"; +import { createStaleWhileRevalidateCache } from "stale-while-revalidate-cache"; const WEBSITE_URL = "https://www.ssw.com.au"; export const EVENTS_MAX_SIZE_OVERRIDE = 999; const DEFAULT_PAGE_SIZE = 10; -export const getPastEvents = async (top, presenterName) => { +const CACHE_MAX_TTL = 60 * 60 * 1000; // 60 mins +const CACHE_STALE_TIME = 15 * 60 * 1000; // 15 mins +const CACHE_LRU_MAX_ENTRIES = 1000; // Maximum number of cache entries + +const VALID_TOP_VALUES = ["10", "20", "50"]; // Limit the values that are cached +const MAX_PRESENTER_NAME_LENGTH = 100; // Limit the cache key length + +const inMemoryStorage = { + store: new LRUCache({ + max: CACHE_LRU_MAX_ENTRIES, + ttl: CACHE_MAX_TTL, + }), + + getItem: async (key) => inMemoryStorage.store.get(key) || null, + setItem: async (key, value) => { + inMemoryStorage.store.set(key, value); + }, + removeItem: async (key) => { + inMemoryStorage.store.delete(key); + }, +}; + +const swr = createStaleWhileRevalidateCache({ + storage: inMemoryStorage, +}); + +const configOverrides = { + maxTimeToLive: CACHE_MAX_TTL, + minTimeToStale: CACHE_STALE_TIME, +}; + +const isValidTop = (top) => + top === undefined || top === null || VALID_TOP_VALUES.includes(top); + +const normalizePresenterName = (name) => { + const trimmedName = name?.trim().toLowerCase().replaceAll(" ", "-") || ""; + return trimmedName.slice(0, MAX_PRESENTER_NAME_LENGTH); +}; + +const generateCacheKey = (type, top, presenterName) => { + if (!isValidTop(top)) return null; + + const normalizedTop = top === undefined ? DEFAULT_PAGE_SIZE : top; + const normalizedPresenterName = normalizePresenterName(presenterName); + return `${type}-${normalizedTop}-${normalizedPresenterName}`; +}; + +const getEvents = async (type: "past" | "upcoming", top, presenterName) => { + const fetchEvents = type === "past" ? fetchPastEvents : fetchUpcomingEvents; + + const cacheKey = generateCacheKey(type, top, presenterName); + if (!cacheKey) { + return fetchEvents(top, presenterName); + } + + return ( + await swr(cacheKey, () => fetchEvents(top, presenterName), configOverrides) + ).value; +}; + +const fetchPastEvents = async (top, presenterName) => { const eventClient = await client.queries.getPastEventsQuery( formatEventParams(top, presenterName) ); return await fetchEventsWithClient(eventClient, presenterName, top); }; -export const getUpcomingEvents = async (top, presenterName) => { +const fetchUpcomingEvents = async (top, presenterName) => { const eventClient = await client.queries.getFutureEventsQuery( formatEventParams(top, presenterName) ); @@ -48,19 +110,18 @@ const formatEvent = (event) => { HasVideo: event.youTubeId ? "Yes" : "No", YouTubeId: event.youTubeId, PresenterDescription: - event.presenterList && event.presenterListlength > 0 + event.presenterList && event.presenterList.length > 0 ? event.presenterList[0].presenter.about : null, }; }; + export const fetchEventsWithClient = async ( eventClient, presenterName: string | undefined, top ) => { - if (top) { - top = parseInt(top); - } + const normalizedTop = parseInt(top) || DEFAULT_PAGE_SIZE; const events = []; /* TODO: remove back end filtering after fixing events with multiple presenters in the name https://github.com/SSWConsulting/SSW.Website/issues/2833 */ @@ -70,7 +131,7 @@ export const fetchEventsWithClient = async ( events.push(formatEvent(event.node)); } - if (events.length === (top || DEFAULT_PAGE_SIZE)) { + if (events.length === normalizedTop) { break; } } @@ -120,3 +181,9 @@ type eventEdge = { presenterName: string | null | undefined; }; }; + +export const getPastEvents = (top, presenterName) => + getEvents("past", top, presenterName); + +export const getUpcomingEvents = (top, presenterName) => + getEvents("upcoming", top, presenterName);