Skip to content

Commit 551ce57

Browse files
authored
Use Redis Cache for Critical Paths (#145)
Uses Redis Cache for latency-sensitive paths Uses upstash managed redis
1 parent 582a1a2 commit 551ce57

33 files changed

+364
-412
lines changed

cloudformation/iam.yml

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,6 @@ Resources:
9999
Resource:
100100
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-cache
101101

102-
- Sid: DynamoDBRateLimitTableAccess
103-
Effect: Allow
104-
Action:
105-
- dynamodb:DescribeTable
106-
- dynamodb:UpdateItem
107-
Resource:
108-
- Fn::Sub: arn:aws:dynamodb:${AWS::Region}:${AWS::AccountId}:table/infra-core-api-rate-limiter
109-
110102
- Sid: DynamoDBAuditLogTableAccess
111103
Effect: Allow
112104
Action:

cloudformation/main.yml

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -408,30 +408,6 @@ Resources:
408408
- AttributeName: userEmail
409409
KeyType: HASH
410410

411-
RateLimiterTable:
412-
Type: "AWS::DynamoDB::Table"
413-
DeletionPolicy: "Delete"
414-
UpdateReplacePolicy: "Delete"
415-
Properties:
416-
BillingMode: "PAY_PER_REQUEST"
417-
TableName: infra-core-api-rate-limiter
418-
DeletionProtectionEnabled: true
419-
PointInTimeRecoverySpecification:
420-
PointInTimeRecoveryEnabled: false
421-
AttributeDefinitions:
422-
- AttributeName: PK
423-
AttributeType: S
424-
- AttributeName: SK
425-
AttributeType: S
426-
KeySchema:
427-
- AttributeName: PK
428-
KeyType: HASH
429-
- AttributeName: SK
430-
KeyType: RANGE
431-
TimeToLiveSpecification:
432-
AttributeName: ttl
433-
Enabled: true
434-
435411
EventRecordsTable:
436412
Type: "AWS::DynamoDB::Table"
437413
DeletionPolicy: "Retain"

package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
"@eslint/js": "^9.25.1",
3535
"@playwright/test": "^1.52.0",
3636
"@tsconfig/node22": "^22.0.1",
37+
"@types/ioredis-mock": "^8.2.5",
3738
"@types/node": "^22.15.2",
3839
"@types/pluralize": "^0.0.33",
3940
"@types/react": "^19.1.2",
@@ -63,6 +64,7 @@
6364
"eslint-plugin-react-hooks": "^5.2.0",
6465
"husky": "^9.1.4",
6566
"identity-obj-proxy": "^3.0.0",
67+
"ioredis-mock": "^8.9.0",
6668
"jsdom": "^26.1.0",
6769
"node-ical": "^0.20.1",
6870
"postcss": "^8.5.3",
@@ -85,4 +87,4 @@
8587
"resolutions": {
8688
"pdfjs-dist": "^4.8.69"
8789
}
88-
}
90+
}

src/api/build.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ const commonParams = {
2525
"@fastify/swagger",
2626
"@fastify/swagger-ui",
2727
"argon2",
28+
"ioredis",
2829
],
2930
alias: {
3031
"moment-timezone": resolve(

src/api/functions/authorization.ts

Lines changed: 0 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -9,14 +9,8 @@ export const AUTH_DECISION_CACHE_SECONDS = 180;
99

1010
export async function getUserRoles(
1111
dynamoClient: DynamoDBClient,
12-
fastifyApp: FastifyInstance,
1312
userId: string,
1413
): Promise<AppRoles[]> {
15-
const cachedValue = fastifyApp.nodeCache.get(`userroles-${userId}`);
16-
if (cachedValue) {
17-
fastifyApp.log.info(`Returning cached auth decision for user ${userId}`);
18-
return cachedValue as AppRoles[];
19-
}
2014
const tableName = `${genericConfig.IAMTablePrefix}-userroles`;
2115
const command = new GetItemCommand({
2216
TableName: tableName,
@@ -38,31 +32,15 @@ export async function getUserRoles(
3832
return [];
3933
}
4034
if (items.roles[0] === "all") {
41-
fastifyApp.nodeCache.set(
42-
`userroles-${userId}`,
43-
allAppRoles,
44-
AUTH_DECISION_CACHE_SECONDS,
45-
);
4635
return allAppRoles;
4736
}
48-
fastifyApp.nodeCache.set(
49-
`userroles-${userId}`,
50-
items.roles,
51-
AUTH_DECISION_CACHE_SECONDS,
52-
);
5337
return items.roles as AppRoles[];
5438
}
5539

5640
export async function getGroupRoles(
5741
dynamoClient: DynamoDBClient,
58-
fastifyApp: FastifyInstance,
5942
groupId: string,
6043
) {
61-
const cachedValue = fastifyApp.nodeCache.get(`grouproles-${groupId}`);
62-
if (cachedValue) {
63-
fastifyApp.log.info(`Returning cached auth decision for group ${groupId}`);
64-
return cachedValue as AppRoles[];
65-
}
6644
const tableName = `${genericConfig.IAMTablePrefix}-grouproles`;
6745
const command = new GetItemCommand({
6846
TableName: tableName,
@@ -77,34 +55,14 @@ export async function getGroupRoles(
7755
});
7856
}
7957
if (!response.Item) {
80-
fastifyApp.nodeCache.set(
81-
`grouproles-${groupId}`,
82-
[],
83-
AUTH_DECISION_CACHE_SECONDS,
84-
);
8558
return [];
8659
}
8760
const items = unmarshall(response.Item) as { roles: AppRoles[] | ["all"] };
8861
if (!("roles" in items)) {
89-
fastifyApp.nodeCache.set(
90-
`grouproles-${groupId}`,
91-
[],
92-
AUTH_DECISION_CACHE_SECONDS,
93-
);
9462
return [];
9563
}
9664
if (items.roles[0] === "all") {
97-
fastifyApp.nodeCache.set(
98-
`grouproles-${groupId}`,
99-
allAppRoles,
100-
AUTH_DECISION_CACHE_SECONDS,
101-
);
10265
return allAppRoles;
10366
}
104-
fastifyApp.nodeCache.set(
105-
`grouproles-${groupId}`,
106-
items.roles,
107-
AUTH_DECISION_CACHE_SECONDS,
108-
);
10967
return items.roles as AppRoles[];
11068
}

src/api/functions/discord.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import moment from "moment-timezone";
1414
import { FastifyBaseLogger } from "fastify";
1515
import { DiscordEventError } from "../../common/errors/index.js";
1616
import { getSecretValue } from "../plugins/auth.js";
17-
import { genericConfig } from "../../common/config.js";
17+
import { genericConfig, SecretConfig } from "../../common/config.js";
1818
import { SecretsManagerClient } from "@aws-sdk/client-secrets-manager";
1919

2020
// https://stackoverflow.com/a/3809435/5684541
@@ -26,19 +26,16 @@ export type IUpdateDiscord = EventPostRequest & { id: string };
2626
const urlRegex = /https:\/\/[a-z0-9.-]+\/calendar\?id=([a-f0-9-]+)/;
2727

2828
export const updateDiscord = async (
29-
smClient: SecretsManagerClient,
29+
secretApiConfig: SecretConfig,
3030
event: IUpdateDiscord,
3131
actor: string,
3232
isDelete: boolean = false,
3333
logger: FastifyBaseLogger,
3434
): Promise<null | GuildScheduledEventCreateOptions> => {
35-
const secretApiConfig =
36-
(await getSecretValue(smClient, genericConfig.ConfigSecretName)) || {};
3735
const client = new Client({ intents: [GatewayIntentBits.Guilds] });
3836
let payload: GuildScheduledEventCreateOptions | null = null;
39-
4037
client.once(Events.ClientReady, async (readyClient: Client<true>) => {
41-
logger.info(`Logged in as ${readyClient.user.tag}`);
38+
logger.debug(`Logged in as ${readyClient.user.tag}`);
4239
const guildID = secretApiConfig.discord_guild_id;
4340
const guild = await client.guilds.fetch(guildID?.toString() || "");
4441
const discordEvents = await guild.scheduledEvents.fetch();
@@ -69,6 +66,7 @@ export const updateDiscord = async (
6966
logger.warn(`Event with id ${id} not found in Discord`);
7067
}
7168
await client.destroy();
69+
logger.debug("Logged out of Discord.");
7270
return null;
7371
}
7472

@@ -108,6 +106,7 @@ export const updateDiscord = async (
108106
}
109107

110108
await client.destroy();
109+
logger.debug("Logged out of Discord.");
111110
return payload;
112111
});
113112

src/api/functions/rateLimit.ts

Lines changed: 44 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,66 +1,60 @@
1-
import {
2-
ConditionalCheckFailedException,
3-
UpdateItemCommand,
4-
DynamoDBClient,
5-
} from "@aws-sdk/client-dynamodb";
6-
import { genericConfig } from "common/config.js";
1+
import { Redis } from "ioredis"; // Make sure you have ioredis installed (npm install ioredis)
72

83
interface RateLimitParams {
9-
ddbClient: DynamoDBClient;
4+
redisClient: Redis;
105
rateLimitIdentifier: string;
116
duration: number;
127
limit: number;
138
userIdentifier: string;
149
}
1510

11+
interface RateLimitResult {
12+
limited: boolean;
13+
resetTime: number;
14+
used: number;
15+
}
16+
17+
const LUA_SCRIPT_INCREMENT_AND_EXPIRE = `
18+
local count = redis.call("INCR", KEYS[1])
19+
-- If the count is 1, this means the key was just created by INCR (first request in this window).
20+
-- So, we set its expiration time to the end of the current window.
21+
if tonumber(count) == 1 then
22+
redis.call("EXPIREAT", KEYS[1], ARGV[1])
23+
end
24+
return count
25+
`;
26+
1627
export async function isAtLimit({
17-
ddbClient,
28+
redisClient,
1829
rateLimitIdentifier,
1930
duration,
2031
limit,
2132
userIdentifier,
22-
}: RateLimitParams): Promise<{
23-
limited: boolean;
24-
resetTime: number;
25-
used: number;
26-
}> {
33+
}: RateLimitParams): Promise<RateLimitResult> {
34+
if (duration <= 0) {
35+
throw new Error("Rate limit duration must be a positive number.");
36+
}
37+
if (limit < 0) {
38+
throw new Error("Rate limit must be a non-negative number.");
39+
}
40+
2741
const nowInSeconds = Math.floor(Date.now() / 1000);
28-
const timeWindow = Math.floor(nowInSeconds / duration) * duration;
29-
const PK = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindow}`;
42+
const timeWindowStart = Math.floor(nowInSeconds / duration) * duration;
43+
const key = `rate-limit:${rateLimitIdentifier}:${userIdentifier}:${timeWindowStart}`;
44+
const expiryTimestamp = timeWindowStart + duration;
3045

31-
try {
32-
const result = await ddbClient.send(
33-
new UpdateItemCommand({
34-
TableName: genericConfig.RateLimiterDynamoTableName,
35-
Key: {
36-
PK: { S: PK },
37-
SK: { S: "counter" },
38-
},
39-
UpdateExpression: "ADD #rateLimitCount :inc SET #ttl = :ttl",
40-
ConditionExpression:
41-
"attribute_not_exists(#rateLimitCount) OR #rateLimitCount <= :limit",
42-
ExpressionAttributeValues: {
43-
":inc": { N: "1" },
44-
":limit": { N: limit.toString() },
45-
":ttl": { N: (timeWindow + duration).toString() },
46-
},
47-
ExpressionAttributeNames: {
48-
"#rateLimitCount": "rateLimitCount",
49-
"#ttl": "ttl",
50-
},
51-
ReturnValues: "UPDATED_NEW",
52-
ReturnValuesOnConditionCheckFailure: "ALL_OLD",
53-
}),
54-
);
55-
return {
56-
limited: false,
57-
used: parseInt(result.Attributes?.rateLimitCount.N || "1", 10),
58-
resetTime: timeWindow + duration,
59-
};
60-
} catch (error) {
61-
if (error instanceof ConditionalCheckFailedException) {
62-
return { limited: true, resetTime: timeWindow + duration, used: limit };
63-
}
64-
throw error;
65-
}
46+
const currentUsedCount = (await redisClient.eval(
47+
LUA_SCRIPT_INCREMENT_AND_EXPIRE,
48+
1, // Number of keys
49+
key, // KEYS[1]
50+
expiryTimestamp.toString(), // ARGV[1]
51+
)) as number; // The script returns the count, which is a number.
52+
const isLimited = currentUsedCount > limit;
53+
const resetTime = expiryTimestamp;
54+
55+
return {
56+
limited: isLimited,
57+
resetTime,
58+
used: isLimited ? limit : currentUsedCount,
59+
};
6660
}

src/api/index.ts

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,18 @@ import { randomUUID } from "crypto";
44
import fastify, { FastifyInstance } from "fastify";
55
import FastifyAuthProvider from "@fastify/auth";
66
import fastifyStatic from "@fastify/static";
7-
import fastifyAuthPlugin from "./plugins/auth.js";
7+
import fastifyAuthPlugin, { getSecretValue } from "./plugins/auth.js";
88
import protectedRoute from "./routes/protected.js";
99
import errorHandlerPlugin from "./plugins/errorHandler.js";
1010
import { RunEnvironment, runEnvironments } from "../common/roles.js";
1111
import { InternalServerError } from "../common/errors/index.js";
1212
import eventsPlugin from "./routes/events.js";
1313
import cors from "@fastify/cors";
14-
import { environmentConfig, genericConfig } from "../common/config.js";
14+
import {
15+
environmentConfig,
16+
genericConfig,
17+
SecretConfig,
18+
} from "../common/config.js";
1519
import organizationsPlugin from "./routes/organizations.js";
1620
import authorizeFromSchemaPlugin from "./plugins/authorizeFromSchema.js";
1721
import evaluatePoliciesPlugin from "./plugins/evaluatePolicies.js";
@@ -43,6 +47,7 @@ import {
4347
import { ZodOpenApiVersion } from "zod-openapi";
4448
import { withTags } from "./components/index.js";
4549
import apiKeyRoute from "./routes/apiKey.js";
50+
import RedisModule from "ioredis";
4651

4752
dotenv.config();
4853

@@ -56,6 +61,12 @@ async function init(prettyPrint: boolean = false) {
5661
const secretsManagerClient = new SecretsManagerClient({
5762
region: genericConfig.AwsRegion,
5863
});
64+
const secret = (await getSecretValue(
65+
secretsManagerClient,
66+
genericConfig.ConfigSecretName,
67+
)) as SecretConfig;
68+
const redisClient = new RedisModule.default(secret.redis_url);
69+
5970
const transport = prettyPrint
6071
? {
6172
target: "pino-pretty",
@@ -224,6 +235,14 @@ async function init(prettyPrint: boolean = false) {
224235
app.nodeCache = new NodeCache({ checkperiod: 30 });
225236
app.dynamoClient = dynamoClient;
226237
app.secretsManagerClient = secretsManagerClient;
238+
app.redisClient = redisClient;
239+
app.secretConfig = secret;
240+
app.refreshSecretConfig = async () => {
241+
app.secretConfig = (await getSecretValue(
242+
app.secretsManagerClient,
243+
genericConfig.ConfigSecretName,
244+
)) as SecretConfig;
245+
};
227246
app.addHook("onRequest", (req, _, done) => {
228247
req.startTime = now();
229248
const hostname = req.hostname;
@@ -250,7 +269,13 @@ async function init(prettyPrint: boolean = false) {
250269
summary: "Verify that the API server is healthy.",
251270
}),
252271
},
253-
(_, reply) => reply.send({ message: "UP" }),
272+
async (_, reply) => {
273+
const startTime = new Date().getTime();
274+
await app.redisClient.ping();
275+
const redisTime = new Date().getTime();
276+
app.log.debug(`Redis latency: ${redisTime - startTime} ms.`);
277+
return reply.send({ message: "UP" });
278+
},
254279
);
255280
await app.register(
256281
async (api, _options) => {
@@ -295,7 +320,7 @@ if (import.meta.url === `file://${process.argv[1]}`) {
295320
process.exit(1);
296321
}
297322
const app = await init(true);
298-
app.listen({ port: 8080 }, async (err) => {
323+
app.listen({ port: 8080 }, (err) => {
299324
/* eslint no-console: ["error", {"allow": ["log", "error"]}] */
300325
if (err) {
301326
console.error(err);

0 commit comments

Comments
 (0)