From 1e28437596cc3aeb110a1e30c2aa603fb9ef675d Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 5 Jul 2025 12:59:17 -0500 Subject: [PATCH 1/2] cache membership in redis, cache longer as we control invalidation --- src/api/functions/membership.ts | 2 + src/api/routes/membership.ts | 70 ++++++++++++---------- src/api/sqs/handlers/provisionNewMember.ts | 28 +++++++-- 3 files changed, 66 insertions(+), 34 deletions(-) diff --git a/src/api/functions/membership.ts b/src/api/functions/membership.ts index ad89d31a..918d438c 100644 --- a/src/api/functions/membership.ts +++ b/src/api/functions/membership.ts @@ -16,6 +16,8 @@ import { EntraGroupError } from "common/errors/index.js"; import { EntraGroupActions } from "common/types/iam.js"; import { pollUntilNoError } from "./general.js"; +export const MEMBER_CACHE_SECONDS = 43200; // 12 hours + export async function checkExternalMembership( netId: string, list: string, diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 5a2e775a..5d7e6fe2 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -3,6 +3,7 @@ import { checkPaidMembershipFromEntra, checkPaidMembershipFromTable, setPaidMembershipInTable, + MEMBER_CACHE_SECONDS, } from "api/functions/membership.js"; import { validateNetId } from "api/functions/validation.js"; import { FastifyPluginAsync } from "fastify"; @@ -26,9 +27,7 @@ import rawbody from "fastify-raw-body"; import { FastifyZodOpenApiTypeProvider } from "fastify-zod-openapi"; import { z } from "zod"; import { withTags } from "api/components/index.js"; - -const NONMEMBER_CACHE_SECONDS = 60; // 1 minute -const MEMBER_CACHE_SECONDS = 43200; // 12 hours +import { getKey, setKey } from "api/functions/redisCache.js"; const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { await fastify.register(rawbody, { @@ -134,11 +133,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { message: `${netId} is already a paid member!`, }); } - fastify.nodeCache.set( - `isMember_${netId}`, - false, - NONMEMBER_CACHE_SECONDS, - ); + fastify.nodeCache.set(`isMember_${netId}`, false, MEMBER_CACHE_SECONDS); const secretApiConfig = (await getSecretValue( fastify.secretsManagerClient, @@ -190,11 +185,17 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { const netId = request.params.netId.toLowerCase(); const list = request.query.list || "acmpaid"; - if (fastify.nodeCache.get(`isMember_${netId}_${list}`) !== undefined) { + const cacheKey = `membership:${netId}:${list}`; + const result = await getKey<{ isMember: boolean }>({ + redisClient: fastify.redisClient, + key: cacheKey, + logger: request.log, + }); + if (result) { return reply.header("X-ACM-Data-Source", "cache").send({ netId, list: list === "acmpaid" ? undefined : list, - isPaidMember: fastify.nodeCache.get(`isMember_${netId}_${list}`), + isPaidMember: result.isMember, }); } if (list !== "acmpaid") { @@ -203,11 +204,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { list, fastify.dynamoClient, ); - fastify.nodeCache.set( - `isMember_${netId}_${list}`, - isMember, - MEMBER_CACHE_SECONDS, - ); + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); return reply.header("X-ACM-Data-Source", "dynamo").send({ netId, list, @@ -219,11 +222,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.dynamoClient, ); if (isDynamoMember) { - fastify.nodeCache.set( - `isMember_${netId}_${list}`, - true, - MEMBER_CACHE_SECONDS, - ); + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: true }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); return reply .header("X-ACM-Data-Source", "dynamo") .send({ netId, isPaidMember: true }); @@ -241,22 +246,26 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { paidMemberGroup, ); if (isAadMember) { - fastify.nodeCache.set( - `isMember_${netId}_${list}`, - true, - MEMBER_CACHE_SECONDS, - ); + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: true }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); reply .header("X-ACM-Data-Source", "aad") .send({ netId, isPaidMember: true }); await setPaidMembershipInTable(netId, fastify.dynamoClient); return; } - fastify.nodeCache.set( - `isMember_${netId}_${list}`, - false, - NONMEMBER_CACHE_SECONDS, - ); + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: false }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); return reply .header("X-ACM-Data-Source", "aad") .send({ netId, isPaidMember: false }); @@ -315,6 +324,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { ) { const customerEmail = event.data.object.customer_email; if (!customerEmail) { + request.log.info("No customer email found."); return reply .code(200) .send({ handled: false, requestId: request.id }); diff --git a/src/api/sqs/handlers/provisionNewMember.ts b/src/api/sqs/handlers/provisionNewMember.ts index 3e5ef5e9..268774c6 100644 --- a/src/api/sqs/handlers/provisionNewMember.ts +++ b/src/api/sqs/handlers/provisionNewMember.ts @@ -1,13 +1,18 @@ import { AvailableSQSFunctions } from "common/types/sqsMessage.js"; import { currentEnvironmentConfig, SQSHandlerFunction } from "../index.js"; import { getEntraIdToken } from "../../../api/functions/entraId.js"; -import { genericConfig } from "../../../common/config.js"; +import { genericConfig, SecretConfig } from "../../../common/config.js"; -import { setPaidMembership } from "api/functions/membership.js"; +import { + MEMBER_CACHE_SECONDS, + setPaidMembership, +} from "api/functions/membership.js"; import { createAuditLogEntry } from "api/functions/auditLog.js"; import { Modules } from "common/modules.js"; -import { getAuthorizedClients } from "../utils.js"; +import { getAuthorizedClients, getSecretConfig } from "../utils.js"; import { emailMembershipPassHandler } from "./emailMembershipPassHandler.js"; +import RedisModule from "ioredis"; +import { setKey } from "api/functions/redisCache.js"; export const provisionNewMemberHandler: SQSHandlerFunction< AvailableSQSFunctions.ProvisionNewMember @@ -21,9 +26,16 @@ export const provisionNewMemberHandler: SQSHandlerFunction< secretName: genericConfig.EntraSecretName, logger, }); + const secretConfig: SecretConfig = await getSecretConfig({ + logger, + commonConfig, + }); + const redisClient = new RedisModule.default(secretConfig.redis_url); + const netId = email.replace("@illinois.edu", ""); + const cacheKey = `membership:${netId}:acmpaid`; logger.info("Got authorized clients and Entra ID token."); const { updated } = await setPaidMembership({ - netId: email.replace("@illinois.edu", ""), + netId, dynamoClient: clients.dynamoClient, entraToken, paidMemberGroup: currentEnvironmentConfig.PaidMemberGroupId, @@ -45,4 +57,12 @@ export const provisionNewMemberHandler: SQSHandlerFunction< } else { logger.info(`${email} was already a paid member.`); } + logger.info("Setting membership in Redis."); + await setKey({ + redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: true }), + expiresIn: MEMBER_CACHE_SECONDS, + logger, + }); }; From e074694a070c8ec9f5e68e0a8d1b0ed7c2f9cd14 Mon Sep 17 00:00:00 2001 From: Dev Singh Date: Sat, 5 Jul 2025 13:03:38 -0500 Subject: [PATCH 2/2] manage cache in checkout area --- src/api/routes/membership.ts | 51 +++++++++++++++++++++++++----------- 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/src/api/routes/membership.ts b/src/api/routes/membership.ts index 5d7e6fe2..faec5885 100644 --- a/src/api/routes/membership.ts +++ b/src/api/routes/membership.ts @@ -88,7 +88,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { }, async (request, reply) => { const netId = request.params.netId.toLowerCase(); - if (fastify.nodeCache.get(`isMember_${netId}`) === true) { + const cacheKey = `membership:${netId}:acmpaid`; + const result = await getKey<{ isMember: boolean }>({ + redisClient: fastify.redisClient, + key: cacheKey, + logger: request.log, + }); + if (result && result.isMember) { throw new ValidationError({ message: `${netId} is already a paid member!`, }); @@ -98,11 +104,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { fastify.dynamoClient, ); if (isDynamoMember) { - fastify.nodeCache.set( - `isMember_${netId}`, - true, - MEMBER_CACHE_SECONDS, - ); + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: true }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); throw new ValidationError({ message: `${netId} is already a paid member!`, }); @@ -120,11 +128,13 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { paidMemberGroup, ); if (isAadMember) { - fastify.nodeCache.set( - `isMember_${netId}`, - true, - MEMBER_CACHE_SECONDS, - ); + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: true }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); reply .header("X-ACM-Data-Source", "aad") .send({ netId, isPaidMember: true }); @@ -133,7 +143,14 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { message: `${netId} is already a paid member!`, }); } - fastify.nodeCache.set(`isMember_${netId}`, false, MEMBER_CACHE_SECONDS); + // Once the caller becomes a member, the stripe webhook will handle changing this to true + await setKey({ + redisClient: fastify.redisClient, + key: cacheKey, + data: JSON.stringify({ isMember: false }), + expiresIn: MEMBER_CACHE_SECONDS, + logger: request.log, + }); const secretApiConfig = (await getSecretValue( fastify.secretsManagerClient, @@ -185,6 +202,8 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { async (request, reply) => { const netId = request.params.netId.toLowerCase(); const list = request.query.list || "acmpaid"; + // we don't control external list as its direct upload in Dynamo, cache only for 60 seconds. + const ourCacheSeconds = list === "acmpaid" ? MEMBER_CACHE_SECONDS : 60; const cacheKey = `membership:${netId}:${list}`; const result = await getKey<{ isMember: boolean }>({ redisClient: fastify.redisClient, @@ -208,7 +227,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { redisClient: fastify.redisClient, key: cacheKey, data: JSON.stringify({ isMember }), - expiresIn: MEMBER_CACHE_SECONDS, + expiresIn: ourCacheSeconds, logger: request.log, }); return reply.header("X-ACM-Data-Source", "dynamo").send({ @@ -226,7 +245,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { redisClient: fastify.redisClient, key: cacheKey, data: JSON.stringify({ isMember: true }), - expiresIn: MEMBER_CACHE_SECONDS, + expiresIn: ourCacheSeconds, logger: request.log, }); return reply @@ -250,7 +269,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { redisClient: fastify.redisClient, key: cacheKey, data: JSON.stringify({ isMember: true }), - expiresIn: MEMBER_CACHE_SECONDS, + expiresIn: ourCacheSeconds, logger: request.log, }); reply @@ -263,7 +282,7 @@ const membershipPlugin: FastifyPluginAsync = async (fastify, _options) => { redisClient: fastify.redisClient, key: cacheKey, data: JSON.stringify({ isMember: false }), - expiresIn: MEMBER_CACHE_SECONDS, + expiresIn: ourCacheSeconds, logger: request.log, }); return reply