redis pt 2: read cache wiring & event based invalidations

This commit is contained in:
2026-03-30 18:18:46 +08:00
parent c2bafc5bb1
commit d12d3e1ec7
14 changed files with 475 additions and 8 deletions

View File

@@ -13,6 +13,7 @@ import {
verify,
} from 'jsonwebtoken';
import { PrismaService } from '../database/prisma.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import type { SocialAuthProfile } from './types/social-auth-profile';
import type {
AuthTokens,
@@ -35,6 +36,7 @@ import {
usernameFromEmail,
} from './auth.utils';
import type { SsoProvider } from './dto/sso-provider';
import { UserEvents } from '../users/events/user.events';
interface SsoStateClaims {
provider: SsoProvider;
@@ -56,6 +58,7 @@ export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly configService: ConfigService,
private readonly eventEmitter: EventEmitter2,
) {
this.jwtSecret = this.configService.get<string>('JWT_SECRET') || '';
this.jwtIssuer =
@@ -254,6 +257,10 @@ export class AuthService {
},
});
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, {
userId: user.id,
});
return user;
}
@@ -273,7 +280,7 @@ export class AuthService {
);
}
return this.prisma.$transaction(async (tx) => {
const user = await this.prisma.$transaction(async (tx) => {
let user = await tx.user.findUnique({
where: { email },
});
@@ -311,6 +318,12 @@ export class AuthService {
return user;
});
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, {
userId: user.id,
});
return user;
}
private async resolveUsername(

61
src/common/cache/cache-keys.ts vendored Normal file
View File

@@ -0,0 +1,61 @@
const EMPTY_VALUE_TOKEN = '_';
export const CACHE_NAMESPACE = {
FRIENDS_LIST: 'friends-list',
DOLLS_LIST: 'dolls-list',
USERS_SEARCH: 'users-search',
} as const;
function normalizeKeyPart(value: string | undefined): string {
if (!value) {
return EMPTY_VALUE_TOKEN;
}
return encodeURIComponent(value);
}
export const CACHE_TTL_SECONDS = {
FRIENDS_LIST: 30,
DOLLS_LIST: 30,
USERS_SEARCH: 20,
} as const;
export function friendsListCacheKey(userId: string): string {
return normalizeKeyPart(userId);
}
export function friendsListOwnerTag(userId: string): string {
return `owner:${normalizeKeyPart(userId)}`;
}
export function friendsListDependsOnUserTag(userId: string): string {
return `depends-on:${normalizeKeyPart(userId)}`;
}
export function dollsListCacheKey(
ownerId: string,
requesterId: string,
): string {
return `${normalizeKeyPart(ownerId)}:${normalizeKeyPart(requesterId)}`;
}
export function dollsListOwnerTag(ownerId: string): string {
return `owner:${normalizeKeyPart(ownerId)}`;
}
export function dollsListViewerTag(viewerId: string): string {
return `viewer:${normalizeKeyPart(viewerId)}`;
}
export function usersSearchCacheKey(
username: string | undefined,
excludeUserId: string | undefined,
): string {
return `${normalizeKeyPart(username?.trim().toLowerCase())}:${normalizeKeyPart(excludeUserId)}`;
}
export const USERS_SEARCH_GLOBAL_TAG = 'global';
export function usersSearchUserTag(userId: string): string {
return `user:${normalizeKeyPart(userId)}`;
}

66
src/common/cache/cache-tags.service.ts vendored Normal file
View File

@@ -0,0 +1,66 @@
import { Injectable } from '@nestjs/common';
import { CacheService } from './cache.service';
const CACHE_TAG_SET_TTL_SECONDS = 86_400;
@Injectable()
export class CacheTagsService {
constructor(private readonly cacheService: CacheService) {}
async rememberKeyForTag(
namespace: string,
tag: string,
cacheKey: string,
): Promise<void> {
const redisClient = this.cacheService.getRedisClient();
if (!redisClient) {
return;
}
const tagSetKey = this.getTagSetKey(namespace, tag);
const keyWithNamespace = this.cacheService.getNamespacedKey(
namespace,
cacheKey,
);
try {
await Promise.all([
redisClient.sadd(tagSetKey, keyWithNamespace),
redisClient.expire(tagSetKey, CACHE_TAG_SET_TTL_SECONDS),
]);
} catch (error) {
this.cacheService.recordError('tag remember', tagSetKey, error);
}
}
async invalidateTag(namespace: string, tag: string): Promise<void> {
const redisClient = this.cacheService.getRedisClient();
if (!redisClient) {
return;
}
const tagSetKey = this.getTagSetKey(namespace, tag);
try {
const keys = await redisClient.smembers(tagSetKey);
if (keys.length === 0) {
await redisClient.del(tagSetKey);
return;
}
const pipeline = redisClient.pipeline();
keys.forEach((key) => pipeline.del(key));
pipeline.del(tagSetKey);
await pipeline.exec();
} catch (error) {
this.cacheService.recordError('tag invalidate', tagSetKey, error);
}
}
private getTagSetKey(namespace: string, tag: string): string {
return this.cacheService.getNamespacedKey(
'cache-tag',
`${namespace}:${tag}`,
);
}
}

View File

@@ -1,12 +1,13 @@
import { Global, Module } from '@nestjs/common';
import { RedisModule } from '../../database/redis.module';
import { CacheTagsService } from './cache-tags.service';
import { CacheService } from './cache.service';
import { RedisThrottlerStorage } from './redis-throttler.storage';
@Global()
@Module({
imports: [RedisModule],
providers: [CacheService, RedisThrottlerStorage],
exports: [CacheService, RedisThrottlerStorage],
providers: [CacheService, CacheTagsService, RedisThrottlerStorage],
exports: [CacheService, CacheTagsService, RedisThrottlerStorage],
})
export class CacheModule {}

View File

@@ -0,0 +1,37 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CacheTagsService } from '../common/cache/cache-tags.service';
import { CACHE_NAMESPACE, dollsListOwnerTag } from '../common/cache/cache-keys';
import { DollEvents } from './events/doll.events';
import {
type DollCreatedEvent,
type DollDeletedEvent,
type DollUpdatedEvent,
} from './events/doll.events';
@Injectable()
export class DollsCacheInvalidationService {
constructor(private readonly cacheTagsService: CacheTagsService) {}
@OnEvent(DollEvents.DOLL_CREATED)
async handleDollCreated(payload: DollCreatedEvent): Promise<void> {
await this.invalidateOwnerLists(payload.userId);
}
@OnEvent(DollEvents.DOLL_UPDATED)
async handleDollUpdated(payload: DollUpdatedEvent): Promise<void> {
await this.invalidateOwnerLists(payload.userId);
}
@OnEvent(DollEvents.DOLL_DELETED)
async handleDollDeleted(payload: DollDeletedEvent): Promise<void> {
await this.invalidateOwnerLists(payload.userId);
}
private async invalidateOwnerLists(ownerId: string): Promise<void> {
await this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.DOLLS_LIST,
dollsListOwnerTag(ownerId),
);
}
}

View File

@@ -1,6 +1,7 @@
import { Module, forwardRef } from '@nestjs/common';
import { DollsService } from './dolls.service';
import { DollsController } from './dolls.controller';
import { DollsCacheInvalidationService } from './dolls-cache-invalidation.service';
import { DollsNotificationService } from './dolls-notification.service';
import { DatabaseModule } from '../database/database.module';
import { AuthModule } from '../auth/auth.module';
@@ -9,7 +10,11 @@ import { WsModule } from '../ws/ws.module';
@Module({
imports: [DatabaseModule, AuthModule, forwardRef(() => WsModule)],
controllers: [DollsController],
providers: [DollsService, DollsNotificationService],
providers: [
DollsService,
DollsNotificationService,
DollsCacheInvalidationService,
],
exports: [DollsService],
})
export class DollsModule {}

View File

@@ -15,6 +15,15 @@ import {
DollUpdatedEvent,
DollDeletedEvent,
} from './events/doll.events';
import { CacheService } from '../common/cache/cache.service';
import { CacheTagsService } from '../common/cache/cache-tags.service';
import {
CACHE_NAMESPACE,
CACHE_TTL_SECONDS,
dollsListCacheKey,
dollsListOwnerTag,
dollsListViewerTag,
} from '../common/cache/cache-keys';
@Injectable()
export class DollsService {
@@ -23,6 +32,8 @@ export class DollsService {
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
private readonly cacheService: CacheService,
private readonly cacheTagsService: CacheTagsService,
) {}
async getFriendIds(userId: string): Promise<string[]> {
@@ -76,6 +87,48 @@ export class DollsService {
async listByOwner(
ownerId: string,
requestingUserId: string,
): Promise<Doll[]> {
const cacheKey = dollsListCacheKey(ownerId, requestingUserId);
const namespacedKey = this.cacheService.getNamespacedKey(
CACHE_NAMESPACE.DOLLS_LIST,
cacheKey,
);
const cached = await this.cacheService.get(namespacedKey);
if (cached) {
try {
return JSON.parse(cached) as Doll[];
} catch (error) {
this.cacheService.recordError('dolls list parse', namespacedKey, error);
}
}
const dolls = await this.listByOwnerFromDatabase(ownerId, requestingUserId);
await this.cacheService.set(
namespacedKey,
JSON.stringify(dolls),
CACHE_TTL_SECONDS.DOLLS_LIST,
);
await Promise.all([
this.cacheTagsService.rememberKeyForTag(
CACHE_NAMESPACE.DOLLS_LIST,
dollsListOwnerTag(ownerId),
cacheKey,
),
this.cacheTagsService.rememberKeyForTag(
CACHE_NAMESPACE.DOLLS_LIST,
dollsListViewerTag(requestingUserId),
cacheKey,
),
]);
return dolls;
}
private async listByOwnerFromDatabase(
ownerId: string,
requestingUserId: string,
): Promise<Doll[]> {
// If requesting own dolls, no need to check friendship
if (ownerId === requestingUserId) {

View File

@@ -0,0 +1,65 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CacheTagsService } from '../common/cache/cache-tags.service';
import {
CACHE_NAMESPACE,
dollsListViewerTag,
friendsListDependsOnUserTag,
friendsListOwnerTag,
} from '../common/cache/cache-keys';
import { FriendEvents } from './events/friend.events';
import type {
FriendRequestAcceptedEvent,
UnfriendedEvent,
} from './events/friend.events';
@Injectable()
export class FriendsCacheInvalidationService {
constructor(private readonly cacheTagsService: CacheTagsService) {}
@OnEvent(FriendEvents.REQUEST_ACCEPTED)
async handleFriendAccepted(
payload: FriendRequestAcceptedEvent,
): Promise<void> {
const senderId = payload.friendRequest.senderId;
const receiverId = payload.friendRequest.receiverId;
await this.invalidateFriendAndDollViews(senderId, receiverId);
}
@OnEvent(FriendEvents.UNFRIENDED)
async handleUnfriended(payload: UnfriendedEvent): Promise<void> {
await this.invalidateFriendAndDollViews(payload.userId, payload.friendId);
}
private async invalidateFriendAndDollViews(
firstUserId: string,
secondUserId: string,
): Promise<void> {
await Promise.all([
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.FRIENDS_LIST,
friendsListOwnerTag(firstUserId),
),
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.FRIENDS_LIST,
friendsListOwnerTag(secondUserId),
),
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.FRIENDS_LIST,
friendsListDependsOnUserTag(firstUserId),
),
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.FRIENDS_LIST,
friendsListDependsOnUserTag(secondUserId),
),
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.DOLLS_LIST,
dollsListViewerTag(firstUserId),
),
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.DOLLS_LIST,
dollsListViewerTag(secondUserId),
),
]);
}
}

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { FriendsController } from './friends.controller';
import { FriendsCacheInvalidationService } from './friends-cache-invalidation.service';
import { FriendsService } from './friends.service';
import { FriendsNotificationService } from './friends-notification.service';
import { DatabaseModule } from '../database/database.module';
@@ -15,7 +16,11 @@ import { WsModule } from '../ws/ws.module';
forwardRef(() => WsModule),
],
controllers: [FriendsController],
providers: [FriendsService, FriendsNotificationService],
providers: [
FriendsService,
FriendsNotificationService,
FriendsCacheInvalidationService,
],
exports: [FriendsService],
})
export class FriendsModule {}

View File

@@ -15,6 +15,15 @@ import {
FriendRequestDeniedEvent,
UnfriendedEvent,
} from './events/friend.events';
import { CacheService } from '../common/cache/cache.service';
import { CacheTagsService } from '../common/cache/cache-tags.service';
import {
CACHE_NAMESPACE,
CACHE_TTL_SECONDS,
friendsListCacheKey,
friendsListDependsOnUserTag,
friendsListOwnerTag,
} from '../common/cache/cache-keys';
export type FriendRequestWithRelations = FriendRequest & {
sender: User;
@@ -28,6 +37,8 @@ export class FriendsService {
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
private readonly cacheService: CacheService,
private readonly cacheTagsService: CacheTagsService,
) {}
async sendFriendRequest(
@@ -272,7 +283,28 @@ export class FriendsService {
}
async getFriends(userId: string) {
return this.prisma.friendship.findMany({
const cacheKey = friendsListCacheKey(userId);
const namespacedKey = this.cacheService.getNamespacedKey(
CACHE_NAMESPACE.FRIENDS_LIST,
cacheKey,
);
const cached = await this.cacheService.get(namespacedKey);
if (cached) {
try {
return JSON.parse(cached) as Awaited<
ReturnType<PrismaService['friendship']['findMany']>
>;
} catch (error) {
this.cacheService.recordError(
'friends list parse',
namespacedKey,
error,
);
}
}
const friendships = await this.prisma.friendship.findMany({
where: { userId },
include: {
friend: {
@@ -285,6 +317,29 @@ export class FriendsService {
createdAt: 'desc',
},
});
await this.cacheService.set(
namespacedKey,
JSON.stringify(friendships),
CACHE_TTL_SECONDS.FRIENDS_LIST,
);
const dependentFriendTags = friendships.map((friendship) =>
friendsListDependsOnUserTag(friendship.friendId),
);
const tags = [friendsListOwnerTag(userId), ...dependentFriendTags];
await Promise.all(
tags.map((tag) =>
this.cacheTagsService.rememberKeyForTag(
CACHE_NAMESPACE.FRIENDS_LIST,
tag,
cacheKey,
),
),
);
return friendships;
}
async unfriend(userId: string, friendId: string): Promise<void> {

View File

@@ -2,6 +2,7 @@ import { Doll } from '@prisma/client';
export const UserEvents = {
ACTIVE_DOLL_CHANGED: 'user.active-doll.changed',
SEARCH_INDEX_INVALIDATED: 'user.search-index.invalidated',
} as const;
export interface UserActiveDollChangedEvent {
@@ -9,3 +10,7 @@ export interface UserActiveDollChangedEvent {
dollId: string | null;
doll: Doll | null;
}
export interface UserSearchIndexInvalidatedEvent {
userId?: string;
}

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { CacheTagsService } from '../common/cache/cache-tags.service';
import {
CACHE_NAMESPACE,
USERS_SEARCH_GLOBAL_TAG,
usersSearchUserTag,
} from '../common/cache/cache-keys';
import { UserEvents } from './events/user.events';
import type { UserSearchIndexInvalidatedEvent } from './events/user.events';
@Injectable()
export class UsersCacheInvalidationService {
constructor(private readonly cacheTagsService: CacheTagsService) {}
@OnEvent(UserEvents.SEARCH_INDEX_INVALIDATED)
async handleSearchIndexInvalidation(
payload: UserSearchIndexInvalidatedEvent,
): Promise<void> {
const tasks: Promise<void>[] = [
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.USERS_SEARCH,
USERS_SEARCH_GLOBAL_TAG,
),
];
if (payload.userId) {
tasks.push(
this.cacheTagsService.invalidateTag(
CACHE_NAMESPACE.USERS_SEARCH,
usersSearchUserTag(payload.userId),
),
);
}
await Promise.all(tasks);
}
}

View File

@@ -1,5 +1,6 @@
import { Module, forwardRef } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersCacheInvalidationService } from './users-cache-invalidation.service';
import { UsersController } from './users.controller';
import { UsersNotificationService } from './users-notification.service';
import { AuthModule } from '../auth/auth.module';
@@ -16,7 +17,11 @@ import { WsModule } from '../ws/ws.module';
*/
@Module({
imports: [forwardRef(() => AuthModule), forwardRef(() => WsModule)],
providers: [UsersService, UsersNotificationService],
providers: [
UsersService,
UsersNotificationService,
UsersCacheInvalidationService,
],
controllers: [UsersController],
exports: [UsersService],
})

View File

@@ -10,6 +10,15 @@ import { User, Prisma } from '@prisma/client';
import type { UpdateUserDto } from './dto/update-user.dto';
import { UserEvents } from './events/user.events';
import { normalizeEmail } from '../auth/auth.utils';
import { CacheService } from '../common/cache/cache.service';
import { CacheTagsService } from '../common/cache/cache-tags.service';
import {
CACHE_NAMESPACE,
CACHE_TTL_SECONDS,
usersSearchUserTag,
usersSearchCacheKey,
USERS_SEARCH_GLOBAL_TAG,
} from '../common/cache/cache-keys';
export interface CreateLocalUserDto {
email: string;
@@ -30,6 +39,8 @@ export class UsersService {
constructor(
private readonly prisma: PrismaService,
private readonly eventEmitter: EventEmitter2,
private readonly cacheService: CacheService,
private readonly cacheTagsService: CacheTagsService,
) {}
// Legacy Keycloak user creation removed in favor of local auth.
@@ -92,6 +103,8 @@ export class UsersService {
data: updateData,
});
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { userId: id });
this.logger.log(`User ${id} profile update requested`);
return updatedUser;
@@ -121,6 +134,8 @@ export class UsersService {
where: { id },
});
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, { userId: id });
this.logger.log(`User ${id} deleted their account`);
}
@@ -128,6 +143,25 @@ export class UsersService {
username?: string,
excludeUserId?: string,
): Promise<User[]> {
const cacheKey = usersSearchCacheKey(username, excludeUserId);
const namespacedKey = this.cacheService.getNamespacedKey(
CACHE_NAMESPACE.USERS_SEARCH,
cacheKey,
);
const cached = await this.cacheService.get(namespacedKey);
if (cached) {
try {
return JSON.parse(cached) as User[];
} catch (error) {
this.cacheService.recordError(
'users search parse',
namespacedKey,
error,
);
}
}
const where: Prisma.UserWhereInput = {};
if (username) {
@@ -151,6 +185,24 @@ export class UsersService {
},
});
await this.cacheService.set(
namespacedKey,
JSON.stringify(users),
CACHE_TTL_SECONDS.USERS_SEARCH,
);
await this.cacheTagsService.rememberKeyForTag(
CACHE_NAMESPACE.USERS_SEARCH,
USERS_SEARCH_GLOBAL_TAG,
cacheKey,
);
if (excludeUserId) {
await this.cacheTagsService.rememberKeyForTag(
CACHE_NAMESPACE.USERS_SEARCH,
usersSearchUserTag(excludeUserId),
cacheKey,
);
}
return users;
}
@@ -251,7 +303,7 @@ export class UsersService {
const now = new Date();
const roles: string[] = [];
return this.prisma.user.create({
const user = await this.prisma.user.create({
data: {
email: normalizeEmail(createDto.email),
name: createDto.name,
@@ -262,6 +314,12 @@ export class UsersService {
keycloakSub: null,
} as unknown as Prisma.UserUncheckedCreateInput,
});
this.eventEmitter.emit(UserEvents.SEARCH_INDEX_INVALIDATED, {
userId: user.id,
});
return user;
}
async updatePasswordHash(