From 0d04adf4fd21b81d005fc165360d64bef85a46ce Mon Sep 17 00:00:00 2001 From: Ahmad <103906421+ahmadk953@users.noreply.github.com> Date: Wed, 26 Feb 2025 21:35:01 -0500 Subject: [PATCH] Added Basic Redis Caching for DB Queries --- config.example.json | 1 + package.json | 1 + src/db/db.ts | 237 +++++++++++++++++++++++++++++-------- src/db/redis.ts | 105 ++++++++++++++++ src/events/memberEvents.ts | 13 +- src/events/ready.ts | 10 ++ src/types/ConfigTypes.ts | 1 + yarn.lock | 76 ++++++++++++ 8 files changed, 386 insertions(+), 58 deletions(-) create mode 100644 src/db/redis.ts diff --git a/config.example.json b/config.example.json index 490683f..28fca15 100644 --- a/config.example.json +++ b/config.example.json @@ -3,6 +3,7 @@ "clientId": "DISCORD_BOT_ID", "guildId": "DISCORD_SERVER_ID", "dbConnectionString": "POSTGRESQL_CONNECTION_STRING", + "redisConnectionString": "REDIS_CONNECTION_STRING", "channels": { "welcome": "WELCOME_CHANNEL_ID", "logs": "LOG_CHAANNEL_ID" diff --git a/package.json b/package.json index 0b5eec4..b2f7039 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@napi-rs/canvas": "^0.1.67", "discord.js": "^14.18.0", "drizzle-orm": "^0.40.0", + "ioredis": "^5.5.0", "pg": "^8.13.3" }, "devDependencies": { diff --git a/src/db/db.ts b/src/db/db.ts index 81d7e49..ebc2db8 100644 --- a/src/db/db.ts +++ b/src/db/db.ts @@ -1,8 +1,10 @@ import pkg from 'pg'; import { drizzle } from 'drizzle-orm/node-postgres'; -import * as schema from './schema.js'; import { eq } from 'drizzle-orm'; + +import * as schema from './schema.js'; import { loadConfig } from '../util/configLoader.js'; +import { del, exists, getJson, setJson } from './redis.js'; const { Pool } = pkg; const config = loadConfig(); @@ -13,41 +15,121 @@ const dbPool = new Pool({ }); export const db = drizzle({ client: dbPool, schema }); +class DatabaseError extends Error { + constructor( + message: string, + public originalError?: Error, + ) { + super(message); + this.name = 'DatabaseError'; + } +} + export async function getAllMembers() { - return await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.currentlyInServer, true)); + try { + if (await exists('nonBotMembers')) { + const memberData = + await getJson<(typeof schema.memberTable.$inferSelect)[]>( + 'nonBotMembers', + ); + if (memberData && memberData.length > 0) { + return memberData; + } else { + await del('nonBotMembers'); + return await getAllMembers(); + } + } else { + const nonBotMembers = await db + .select() + .from(schema.memberTable) + .where(eq(schema.memberTable.currentlyInServer, true)); + await setJson<(typeof schema.memberTable.$inferSelect)[]>( + 'nonBotMembers', + nonBotMembers, + ); + return nonBotMembers; + } + } catch (error) { + console.error('Error getting all members: ', error); + throw new DatabaseError('Failed to get all members: ', error as Error); + } } export async function setMembers(nonBotMembers: any) { - nonBotMembers.forEach(async (member: any) => { - const memberExists = await db - .select() - .from(schema.memberTable) - .where(eq(schema.memberTable.discordId, member.user.id)); - if (memberExists.length > 0) { - await db - .update(schema.memberTable) - .set({ discordUsername: member.user.username }) + try { + nonBotMembers.forEach(async (member: any) => { + const memberInfo = await db + .select() + .from(schema.memberTable) .where(eq(schema.memberTable.discordId, member.user.id)); - } else { - const members: typeof schema.memberTable.$inferInsert = { - discordId: member.user.id, - discordUsername: member.user.username, - }; - await db.insert(schema.memberTable).values(members); - } - }); + if (memberInfo.length > 0) { + await updateMember({ + discordId: member.user.id, + discordUsername: member.user.username, + currentlyInServer: true, + }); + } else { + const members: typeof schema.memberTable.$inferInsert = { + discordId: member.user.id, + discordUsername: member.user.username, + }; + await db.insert(schema.memberTable).values(members); + } + }); + } catch (error) { + console.error('Error setting members: ', error); + throw new DatabaseError('Failed to set members: ', error as Error); + } } export async function getMember(discordId: string) { - return await db.query.memberTable.findFirst({ - where: eq(schema.memberTable.discordId, discordId), - with: { - moderations: true, - }, - }); + try { + if (await exists(`${discordId}-memberInfo`)) { + const cachedMember = await getJson< + typeof schema.memberTable.$inferSelect + >(`${discordId}-memberInfo`); + const cachedModerationHistory = await getJson< + (typeof schema.moderationTable.$inferSelect)[] + >(`${discordId}-moderationHistory`); + + if ( + cachedMember && + 'discordId' in cachedMember && + cachedModerationHistory && + cachedModerationHistory.length > 0 + ) { + return { + ...cachedMember, + moderations: cachedModerationHistory, + }; + } else { + await del(`${discordId}-memberInfo`); + await del(`${discordId}-moderationHistory`); + return await getMember(discordId); + } + } else { + const member = await db.query.memberTable.findFirst({ + where: eq(schema.memberTable.discordId, discordId), + with: { + moderations: true, + }, + }); + + await setJson( + `${discordId}-memberInfo`, + member!, + ); + await setJson<(typeof schema.moderationTable.$inferSelect)[]>( + `${discordId}-moderationHistory`, + member!.moderations, + ); + + return member; + } + } catch (error) { + console.error('Error getting member: ', error); + throw new DatabaseError('Failed to get member: ', error as Error); + } } export async function updateMember({ @@ -56,14 +138,28 @@ export async function updateMember({ currentlyInServer, currentlyBanned, }: schema.memberTableTypes) { - return await db - .update(schema.memberTable) - .set({ - discordUsername, - currentlyInServer, - currentlyBanned, - }) - .where(eq(schema.memberTable.discordId, discordId)); + try { + const result = await db + .update(schema.memberTable) + .set({ + discordUsername, + currentlyInServer, + currentlyBanned, + }) + .where(eq(schema.memberTable.discordId, discordId)); + + if (await exists(`${discordId}-memberInfo`)) { + await del(`${discordId}-memberInfo`); + } + if (await exists('nonBotMembers')) { + await del('nonBotMembers'); + } + + return result; + } catch (error) { + console.error('Error updating member: ', error); + throw new DatabaseError('Failed to update member: ', error as Error); + } } export async function updateMemberModerationHistory({ @@ -76,22 +172,61 @@ export async function updateMemberModerationHistory({ expiresAt, active, }: schema.moderationTableTypes) { - const moderationEntry = { - discordId, - moderatorDiscordId, - action, - reason, - duration, - createdAt, - expiresAt, - active, - }; - return await db.insert(schema.moderationTable).values(moderationEntry); + try { + const moderationEntry = { + discordId, + moderatorDiscordId, + action, + reason, + duration, + createdAt, + expiresAt, + active, + }; + const result = await db + .insert(schema.moderationTable) + .values(moderationEntry); + + if (await exists(`${discordId}-moderationHistory`)) { + await del(`${discordId}-moderationHistory`); + } + if (await exists(`${discordId}-memberInfo`)) { + await del(`${discordId}-memberInfo`); + } + + return result; + } catch (error) { + console.error('Error updating moderation history: ', error); + throw new DatabaseError( + 'Failed to update moderation history: ', + error as Error, + ); + } } export async function getMemberModerationHistory(discordId: string) { - return await db - .select() - .from(schema.moderationTable) - .where(eq(schema.moderationTable.discordId, discordId)); + try { + if (await exists(`${discordId}-moderationHistory`)) { + return await getJson<(typeof schema.moderationTable.$inferSelect)[]>( + `${discordId}-moderationHistory`, + ); + } else { + const moderationHistory = await db + .select() + .from(schema.moderationTable) + .where(eq(schema.moderationTable.discordId, discordId)); + + await setJson<(typeof schema.moderationTable.$inferSelect)[]>( + `${discordId}-moderationHistory`, + moderationHistory, + ); + return moderationHistory; + } + } catch (error) { + console.error('Error getting moderation history: ', error); + throw new DatabaseError( + 'Failed to get moderation history: ', + error as Error, + ); + } } diff --git a/src/db/redis.ts b/src/db/redis.ts new file mode 100644 index 0000000..8938d17 --- /dev/null +++ b/src/db/redis.ts @@ -0,0 +1,105 @@ +import Redis from 'ioredis'; +import { loadConfig } from '../util/configLoader.js'; + +const config = loadConfig(); +const redis = new Redis(config.redisConnectionString); + +class RedisError extends Error { + constructor( + message: string, + public originalError?: Error, + ) { + super(message); + this.name = 'RedisError'; + } +} + +redis.on('error', (error) => { + console.error('Redis connection error:', error); + throw new RedisError('Failed to connect to Redis instance: ', error); +}); + +redis.on('connect', () => { + console.log('Successfully connected to Redis'); +}); + +export async function set( + key: string, + value: string, + ttl?: number, +): Promise<'OK'> { + try { + await redis.set(key, value); + if (ttl) await redis.expire(key, ttl); + } catch (error) { + console.error('Redis set error: ', error); + throw new RedisError(`Failed to set key: ${key}, `, error as Error); + } + return Promise.resolve('OK'); +} + +export async function setJson( + key: string, + value: T, + ttl?: number, +): Promise<'OK'> { + return await set(key, JSON.stringify(value), ttl); +} + +export async function incr(key: string): Promise { + try { + return await redis.incr(key); + } catch (error) { + console.error('Redis increment error: ', error); + throw new RedisError(`Failed to increment key: ${key}, `, error as Error); + } +} + +export async function exists(key: string): Promise { + try { + return (await redis.exists(key)) === 1; + } catch (error) { + console.error('Redis exists error: ', error); + throw new RedisError( + `Failed to check if key exists: ${key}, `, + error as Error, + ); + } +} + +export async function get(key: string): Promise { + try { + return await redis.get(key); + } catch (error) { + console.error('Redis get error: ', error); + throw new RedisError(`Failed to get key: ${key}, `, error as Error); + } +} + +export async function mget(...keys: string[]): Promise<(string | null)[]> { + try { + return await redis.mget(keys); + } catch (error) { + console.error('Redis mget error: ', error); + throw new RedisError(`Failed to get keys: ${keys}, `, error as Error); + } +} + +export async function getJson(key: string): Promise { + const value = await get(key); + if (!value) return null; + try { + return JSON.parse(value) as T; + } catch { + return null; + } +} + +export async function del(key: string): Promise { + try { + return await redis.del(key); + } catch (error) { + console.error('Redis del error: ', error); + throw new RedisError(`Failed to delete key: ${key}, `, error as Error); + } +} diff --git a/src/events/memberEvents.ts b/src/events/memberEvents.ts index 2fe3b24..99b51ba 100644 --- a/src/events/memberEvents.ts +++ b/src/events/memberEvents.ts @@ -17,9 +17,12 @@ export const memberJoin = { } try { - const members = await guild.members.fetch(); - const nonBotMembers = members.filter((m) => !m.user.bot); - await setMembers(nonBotMembers); + await setMembers([ + { + discordId: member.user.id, + discordUsername: member.user.username, + }, + ]); if (!member.user.bot) { const attachment = await generateMemberBanner({ @@ -37,10 +40,6 @@ export const memberJoin = { content: `Welcome to ${guild.name}, we hope you enjoy your stay!`, files: [attachment], }), - updateMember({ - discordId: member.user.id, - currentlyInServer: true, - }), member.roles.add(config.roles.joinRoles), logAction({ guild, diff --git a/src/events/ready.ts b/src/events/ready.ts index a3566f4..1a3b305 100644 --- a/src/events/ready.ts +++ b/src/events/ready.ts @@ -1,9 +1,19 @@ import { Client, Events } from 'discord.js'; +import { setMembers } from '../db/db.js'; +import { loadConfig } from '../util/configLoader.js'; + export default { name: Events.ClientReady, once: true, execute: async (client: Client) => { + const config = loadConfig(); + const members = await client.guilds.cache + .find((guild) => guild.id === config.guildId) + ?.members.fetch(); + const nonBotMembers = members!.filter((m) => !m.user.bot); + await setMembers(nonBotMembers); + console.log(`Ready! Logged in as ${client.user?.tag}`); }, }; diff --git a/src/types/ConfigTypes.ts b/src/types/ConfigTypes.ts index f6723b7..e92ae94 100644 --- a/src/types/ConfigTypes.ts +++ b/src/types/ConfigTypes.ts @@ -3,6 +3,7 @@ export interface Config { clientId: string; guildId: string; dbConnectionString: string; + redisConnectionString: string; channels: { welcome: string; logs: string; diff --git a/yarn.lock b/yarn.lock index 328539d..6b135b5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -780,6 +780,13 @@ __metadata: languageName: node linkType: hard +"@ioredis/commands@npm:^1.1.1": + version: 1.2.0 + resolution: "@ioredis/commands@npm:1.2.0" + checksum: 10c0/a5d3c29dd84d8a28b7c67a441ac1715cbd7337a7b88649c0f17c345d89aa218578d2b360760017c48149ef8a70f44b051af9ac0921a0622c2b479614c4f65b36 + languageName: node + linkType: hard + "@isaacs/cliui@npm:^8.0.2": version: 8.0.2 resolution: "@isaacs/cliui@npm:8.0.2" @@ -1443,6 +1450,13 @@ __metadata: languageName: node linkType: hard +"cluster-key-slot@npm:^1.1.0": + version: 1.1.2 + resolution: "cluster-key-slot@npm:1.1.2" + checksum: 10c0/d7d39ca28a8786e9e801eeb8c770e3c3236a566625d7299a47bb71113fb2298ce1039596acb82590e598c52dbc9b1f088c8f587803e697cb58e1867a95ff94d3 + languageName: node + linkType: hard + "color-convert@npm:^2.0.1": version: 2.0.1 resolution: "color-convert@npm:2.0.1" @@ -1515,6 +1529,13 @@ __metadata: languageName: node linkType: hard +"denque@npm:^2.1.0": + version: 2.1.0 + resolution: "denque@npm:2.1.0" + checksum: 10c0/f9ef81aa0af9c6c614a727cb3bd13c5d7db2af1abf9e6352045b86e85873e629690f6222f4edd49d10e4ccf8f078bbeec0794fafaf61b659c0589d0c511ec363 + languageName: node + linkType: hard + "diff@npm:^4.0.1": version: 4.0.2 resolution: "diff@npm:4.0.2" @@ -2568,6 +2589,23 @@ __metadata: languageName: node linkType: hard +"ioredis@npm:^5.5.0": + version: 5.5.0 + resolution: "ioredis@npm:5.5.0" + dependencies: + "@ioredis/commands": "npm:^1.1.1" + cluster-key-slot: "npm:^1.1.0" + debug: "npm:^4.3.4" + denque: "npm:^2.1.0" + lodash.defaults: "npm:^4.2.0" + lodash.isarguments: "npm:^3.1.0" + redis-errors: "npm:^1.2.0" + redis-parser: "npm:^3.0.0" + standard-as-callback: "npm:^2.1.0" + checksum: 10c0/ba64502fc92d9e05465793fafcd0568cb668af6e2350462b61daadfd499e3a48239d9a723d3ce08b08c93f3f745d05dda91136cdc597d4d485604e6730305305 + languageName: node + linkType: hard + "ip-address@npm:^9.0.5": version: 9.0.5 resolution: "ip-address@npm:9.0.5" @@ -2723,6 +2761,20 @@ __metadata: languageName: node linkType: hard +"lodash.defaults@npm:^4.2.0": + version: 4.2.0 + resolution: "lodash.defaults@npm:4.2.0" + checksum: 10c0/d5b77aeb702caa69b17be1358faece33a84497bcca814897383c58b28a2f8dfc381b1d9edbec239f8b425126a3bbe4916223da2a576bb0411c2cefd67df80707 + languageName: node + linkType: hard + +"lodash.isarguments@npm:^3.1.0": + version: 3.1.0 + resolution: "lodash.isarguments@npm:3.1.0" + checksum: 10c0/5e8f95ba10975900a3920fb039a3f89a5a79359a1b5565e4e5b4310ed6ebe64011e31d402e34f577eca983a1fc01ff86c926e3cbe602e1ddfc858fdd353e62d8 + languageName: node + linkType: hard + "lodash.merge@npm:^4.6.2": version: 4.6.2 resolution: "lodash.merge@npm:4.6.2" @@ -3211,6 +3263,7 @@ __metadata: eslint: "npm:^9.21.0" eslint-config-prettier: "npm:^10.0.2" globals: "npm:^16.0.0" + ioredis: "npm:^5.5.0" pg: "npm:^8.13.3" prettier: "npm:3.5.2" ts-node: "npm:^10.9.2" @@ -3333,6 +3386,22 @@ __metadata: languageName: node linkType: hard +"redis-errors@npm:^1.0.0, redis-errors@npm:^1.2.0": + version: 1.2.0 + resolution: "redis-errors@npm:1.2.0" + checksum: 10c0/5b316736e9f532d91a35bff631335137a4f974927bb2fb42bf8c2f18879173a211787db8ac4c3fde8f75ed6233eb0888e55d52510b5620e30d69d7d719c8b8a7 + languageName: node + linkType: hard + +"redis-parser@npm:^3.0.0": + version: 3.0.0 + resolution: "redis-parser@npm:3.0.0" + dependencies: + redis-errors: "npm:^1.0.0" + checksum: 10c0/ee16ac4c7b2a60b1f42a2cdaee22b005bd4453eb2d0588b8a4939718997ae269da717434da5d570fe0b05030466eeb3f902a58cf2e8e1ca058bf6c9c596f632f + languageName: node + linkType: hard + "resolve-from@npm:^4.0.0": version: 4.0.0 resolution: "resolve-from@npm:4.0.0" @@ -3504,6 +3573,13 @@ __metadata: languageName: node linkType: hard +"standard-as-callback@npm:^2.1.0": + version: 2.1.0 + resolution: "standard-as-callback@npm:2.1.0" + checksum: 10c0/012677236e3d3fdc5689d29e64ea8a599331c4babe86956bf92fc5e127d53f85411c5536ee0079c52c43beb0026b5ce7aa1d834dd35dd026e82a15d1bcaead1f + languageName: node + linkType: hard + "string-width-cjs@npm:string-width@^4.2.0, string-width@npm:^4.1.0": version: 4.2.3 resolution: "string-width@npm:4.2.3"