summaryrefslogtreecommitdiff
path: root/packages/backend/src/core/CacheService.ts
diff options
context:
space:
mode:
authorJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
committerJulia <julia@insertdomain.name>2025-05-29 00:07:38 +0000
commit6b554c178b81f13f83a69b19d44b72b282a0c119 (patch)
treef5537f1a56323a4dd57ba150b3cb84a2d8b5dc63 /packages/backend/src/core/CacheService.ts
parentmerge: Security fixes (!970) (diff)
parentbump version for release (diff)
downloadsharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.gz
sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.tar.bz2
sharkey-6b554c178b81f13f83a69b19d44b72b282a0c119.zip
merge: release 2025.4.2 (!1051)
View MR for information: https://activitypub.software/TransFem-org/Sharkey/-/merge_requests/1051 Approved-by: Hazelnoot <acomputerdog@gmail.com> Approved-by: Marie <github@yuugi.dev> Approved-by: Julia <julia@insertdomain.name>
Diffstat (limited to 'packages/backend/src/core/CacheService.ts')
-rw-r--r--packages/backend/src/core/CacheService.ts121
1 files changed, 120 insertions, 1 deletions
diff --git a/packages/backend/src/core/CacheService.ts b/packages/backend/src/core/CacheService.ts
index 6725ebe75b..1cf63221f9 100644
--- a/packages/backend/src/core/CacheService.ts
+++ b/packages/backend/src/core/CacheService.ts
@@ -5,7 +5,8 @@
import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis';
-import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing } from '@/models/_.js';
+import { IsNull } from 'typeorm';
+import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiFollowing, MiNote } from '@/models/_.js';
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js';
@@ -14,6 +15,24 @@ import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common';
+export interface FollowStats {
+ localFollowing: number;
+ localFollowers: number;
+ remoteFollowing: number;
+ remoteFollowers: number;
+}
+
+export interface CachedTranslation {
+ sourceLang: string | undefined;
+ text: string | undefined;
+}
+
+interface CachedTranslationEntity {
+ l?: string;
+ t?: string;
+ u?: number;
+}
+
@Injectable()
export class CacheService implements OnApplicationShutdown {
public userByIdCache: MemoryKVCache<MiUser>;
@@ -26,6 +45,8 @@ export class CacheService implements OnApplicationShutdown {
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
public renoteMutingsCache: RedisKVCache<Set<string>>;
public userFollowingsCache: RedisKVCache<Record<string, Pick<MiFollowing, 'withReplies'> | undefined>>;
+ private readonly userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
+ private readonly translationsCache: RedisKVCache<CachedTranslationEntity>;
constructor(
@Inject(DI.redis)
@@ -115,6 +136,11 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => JSON.parse(value),
});
+ this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
+ lifetime: 1000 * 60 * 60 * 24 * 7, // 1 week,
+ memoryCacheLifetime: 1000 * 60, // 1 minute
+ });
+
// NOTE: チャンネルのフォロー状況キャッシュはChannelFollowingServiceで行っている
this.redisForSub.on('message', this.onMessage);
@@ -166,6 +192,18 @@ export class CacheService implements OnApplicationShutdown {
const followee = this.userByIdCache.get(body.followeeId);
if (followee) followee.followersCount++;
this.userFollowingsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followeeId);
+ break;
+ }
+ case 'unfollow': {
+ const follower = this.userByIdCache.get(body.followerId);
+ if (follower) follower.followingCount--;
+ const followee = this.userByIdCache.get(body.followeeId);
+ if (followee) followee.followersCount--;
+ this.userFollowingsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followerId);
+ this.userFollowStatsCache.delete(body.followeeId);
break;
}
default:
@@ -180,6 +218,87 @@ export class CacheService implements OnApplicationShutdown {
}
@bindThis
+ public async findLocalUserById(userId: MiUser['id']): Promise<MiLocalUser | null> {
+ return await this.localUserByIdCache.fetchMaybe(userId, async () => {
+ return await this.usersRepository.findOneBy({ id: userId, host: IsNull() }) as MiLocalUser | null ?? undefined;
+ }) ?? null;
+ }
+
+ @bindThis
+ public async getFollowStats(userId: MiUser['id']): Promise<FollowStats> {
+ return await this.userFollowStatsCache.fetch(userId, async () => {
+ const stats = {
+ localFollowing: 0,
+ localFollowers: 0,
+ remoteFollowing: 0,
+ remoteFollowers: 0,
+ };
+
+ const followings = await this.followingsRepository.findBy([
+ { followerId: userId },
+ { followeeId: userId },
+ ]);
+
+ for (const following of followings) {
+ if (following.followerId === userId) {
+ // increment following; user is a follower of someone else
+ if (following.followeeHost == null) {
+ stats.localFollowing++;
+ } else {
+ stats.remoteFollowing++;
+ }
+ } else if (following.followeeId === userId) {
+ // increment followers; user is followed by someone else
+ if (following.followerHost == null) {
+ stats.localFollowers++;
+ } else {
+ stats.remoteFollowers++;
+ }
+ } else {
+ // Should never happen
+ }
+ }
+
+ // Infer remote-remote followers heuristically, since we don't track that info directly.
+ const user = await this.findUserById(userId);
+ if (user.host !== null) {
+ stats.remoteFollowing = Math.max(0, user.followingCount - stats.localFollowing);
+ stats.remoteFollowers = Math.max(0, user.followersCount - stats.localFollowers);
+ }
+
+ return stats;
+ });
+ }
+
+ @bindThis
+ public async getCachedTranslation(note: MiNote, targetLang: string): Promise<CachedTranslation | null> {
+ const cacheKey = `${note.id}@${targetLang}`;
+
+ // Use cached translation, if present and up-to-date
+ const cached = await this.translationsCache.get(cacheKey);
+ if (cached && cached.u === note.updatedAt?.valueOf()) {
+ return {
+ sourceLang: cached.l,
+ text: cached.t,
+ };
+ }
+
+ // No cache entry :(
+ return null;
+ }
+
+ @bindThis
+ public async setCachedTranslation(note: MiNote, targetLang: string, translation: CachedTranslation): Promise<void> {
+ const cacheKey = `${note.id}@${targetLang}`;
+
+ await this.translationsCache.set(cacheKey, {
+ l: translation.sourceLang,
+ t: translation.text,
+ u: note.updatedAt?.valueOf(),
+ });
+ }
+
+ @bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.userByIdCache.dispose();