mirror of
https://codeberg.org/yeentown/barkey.git
synced 2025-07-07 12:36:57 +00:00
normalize userFollowingsCache / userFollowersCache and add hibernatedUserCache to reduce the number of cache-clears and allow use of caching in many more places
This commit is contained in:
parent
372714c9b6
commit
fa68751a19
28 changed files with 816 additions and 581 deletions
|
@ -26,6 +26,7 @@ import PerUserFollowingChart from '@/core/chart/charts/per-user-following.js';
|
|||
import { SystemAccountService } from '@/core/SystemAccountService.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { AntennaService } from '@/core/AntennaService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
@Injectable()
|
||||
export class AccountMoveService {
|
||||
|
@ -68,6 +69,7 @@ export class AccountMoveService {
|
|||
private systemAccountService: SystemAccountService,
|
||||
private roleService: RoleService,
|
||||
private antennaService: AntennaService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -107,12 +109,10 @@ export class AccountMoveService {
|
|||
this.globalEventService.publishMainStream(src.id, 'meUpdated', iObj);
|
||||
|
||||
// Unfollow after 24 hours
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followerId: src.id,
|
||||
});
|
||||
this.queueService.createDelayedUnfollowJob(followings.map(following => ({
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(src.id);
|
||||
this.queueService.createDelayedUnfollowJob(Array.from(followings.keys()).map(followeeId => ({
|
||||
from: { id: src.id },
|
||||
to: { id: following.followeeId },
|
||||
to: { id: followeeId },
|
||||
})), process.env.NODE_ENV === 'test' ? 10000 : 1000 * 60 * 60 * 24);
|
||||
|
||||
await this.postMoveProcess(src, dst);
|
||||
|
@ -138,11 +138,9 @@ export class AccountMoveService {
|
|||
|
||||
// follow the new account
|
||||
const proxy = await this.systemAccountService.fetch('proxy');
|
||||
const followings = await this.followingsRepository.findBy({
|
||||
followeeId: src.id,
|
||||
followerHost: IsNull(), // follower is local
|
||||
followerId: Not(proxy.id),
|
||||
});
|
||||
const followings = await this.cacheService.userFollowersCache.fetch(src.id)
|
||||
.then(fs => Array.from(fs.values())
|
||||
.filter(f => f.followerHost == null && f.followerId !== proxy.id));
|
||||
const followJobs = followings.map(following => ({
|
||||
from: { id: following.followerId },
|
||||
to: { id: dst.id },
|
||||
|
@ -318,9 +316,9 @@ export class AccountMoveService {
|
|||
await this.usersRepository.decrement({ id: In(localFollowerIds) }, 'followingCount', 1);
|
||||
|
||||
// Decrease follower counts of local followees by 1.
|
||||
const oldFollowings = await this.followingsRepository.findBy({ followerId: oldAccount.id });
|
||||
if (oldFollowings.length > 0) {
|
||||
await this.usersRepository.decrement({ id: In(oldFollowings.map(following => following.followeeId)) }, 'followersCount', 1);
|
||||
const oldFollowings = await this.cacheService.userFollowingsCache.fetch(oldAccount.id);
|
||||
if (oldFollowings.size > 0) {
|
||||
await this.usersRepository.decrement({ id: In(Array.from(oldFollowings.keys())) }, 'followersCount', 1);
|
||||
}
|
||||
|
||||
// Update instance stats by decreasing remote followers count by the number of local followers who were following the old account.
|
||||
|
|
|
@ -6,7 +6,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { In, IsNull } from 'typeorm';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MutingsRepository, RenoteMutingsRepository, MiUserProfile, UserProfilesRepository, UsersRepository, MiNote, MiFollowing } from '@/models/_.js';
|
||||
import { MemoryKVCache, RedisKVCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache } from '@/misc/QuantumKVCache.js';
|
||||
import type { MiLocalUser, MiUser } from '@/models/User.js';
|
||||
|
@ -46,8 +46,9 @@ export class CacheService implements OnApplicationShutdown {
|
|||
public userBlockingCache: QuantumKVCache<Set<string>>;
|
||||
public userBlockedCache: QuantumKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
|
||||
public renoteMutingsCache: QuantumKVCache<Set<string>>;
|
||||
public userFollowingsCache: QuantumKVCache<Map<string, { withReplies: boolean }>>;
|
||||
public userFollowersCache: QuantumKVCache<Set<string>>;
|
||||
public userFollowingsCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public userFollowersCache: QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
public hibernatedUserCache: QuantumKVCache<boolean>;
|
||||
protected userFollowStatsCache = new MemoryKVCache<FollowStats>(1000 * 60 * 10); // 10 minutes
|
||||
protected translationsCache: RedisKVCache<CachedTranslationEntity>;
|
||||
|
||||
|
@ -89,36 +90,145 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.userProfileCache = new QuantumKVCache(this.internalEventService, 'userProfile', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.userProfilesRepository.findOneByOrFail({ userId: key }),
|
||||
bulkFetcher: userIds => this.userProfilesRepository.findBy({ userId: In(userIds) }).then(ps => ps.map(p => [p.userId, p])),
|
||||
});
|
||||
|
||||
this.userMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
||||
bulkFetcher: muterIds => this.mutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."muterId"', 'muterId')
|
||||
.addSelect('array_agg("muting"."muteeId")', 'muteeIds')
|
||||
.where({ muterId: In(muterIds) })
|
||||
.groupBy('muting.muterId')
|
||||
.getRawMany<{ muterId: string, muteeIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||
});
|
||||
|
||||
this.userBlockingCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userBlocking', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.blockingsRepository.find({ where: { blockerId: key }, select: ['blockeeId'] }).then(xs => new Set(xs.map(x => x.blockeeId))),
|
||||
bulkFetcher: blockerIds => this.blockingsRepository
|
||||
.createQueryBuilder('blocking')
|
||||
.select('"blocking"."blockerId"', 'blockerId')
|
||||
.addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds')
|
||||
.where({ blockerId: In(blockerIds) })
|
||||
.groupBy('blocking.blockerId')
|
||||
.getRawMany<{ blockerId: string, blockeeIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.blockerId, new Set(m.blockeeIds)])),
|
||||
});
|
||||
|
||||
this.userBlockedCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userBlocked', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.blockingsRepository.find({ where: { blockeeId: key }, select: ['blockerId'] }).then(xs => new Set(xs.map(x => x.blockerId))),
|
||||
bulkFetcher: blockeeIds => this.blockingsRepository
|
||||
.createQueryBuilder('blocking')
|
||||
.select('"blocking"."blockeeId"', 'blockeeId')
|
||||
.addSelect('array_agg("blocking"."blockeeId")', 'blockeeIds')
|
||||
.where({ blockeeId: In(blockeeIds) })
|
||||
.groupBy('blocking.blockeeId')
|
||||
.getRawMany<{ blockeeId: string, blockerIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.blockeeId, new Set(m.blockerIds)])),
|
||||
});
|
||||
|
||||
this.renoteMutingsCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'renoteMutings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.renoteMutingsRepository.find({ where: { muterId: key }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
|
||||
bulkFetcher: muterIds => this.renoteMutingsRepository
|
||||
.createQueryBuilder('muting')
|
||||
.select('"muting"."muterId"', 'muterId')
|
||||
.addSelect('array_agg("muting"."muteeId")', 'muteeIds')
|
||||
.where({ muterId: In(muterIds) })
|
||||
.groupBy('muting.muterId')
|
||||
.getRawMany<{ muterId: string, muteeIds: string[] }>()
|
||||
.then(ms => ms.map(m => [m.muterId, new Set(m.muteeIds)])),
|
||||
});
|
||||
|
||||
this.userFollowingsCache = new QuantumKVCache<Map<string, { withReplies: boolean }>>(this.internalEventService, 'userFollowings', {
|
||||
this.userFollowingsCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowings', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followerId: key }, select: ['followeeId', 'withReplies'] }).then(xs => new Map(xs.map(f => [f.followeeId, { withReplies: f.withReplies }]))),
|
||||
fetcher: (key) => this.followingsRepository.findBy({ followerId: key }).then(xs => new Map(xs.map(f => [f.followeeId, f]))),
|
||||
bulkFetcher: followerIds => this.followingsRepository
|
||||
.findBy({ followerId: In(followerIds) })
|
||||
.then(fs => fs
|
||||
.reduce((groups, f) => {
|
||||
let group = groups.get(f.followerId);
|
||||
if (!group) {
|
||||
group = new Map();
|
||||
groups.set(f.followerId, group);
|
||||
}
|
||||
group.set(f.followeeId, f);
|
||||
return groups;
|
||||
}, {} as Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>)),
|
||||
});
|
||||
|
||||
this.userFollowersCache = new QuantumKVCache<Set<string>>(this.internalEventService, 'userFollowers', {
|
||||
this.userFollowersCache = new QuantumKVCache<Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>(this.internalEventService, 'userFollowers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: (key) => this.followingsRepository.find({ where: { followeeId: key }, select: ['followerId'] }).then(xs => new Set(xs.map(x => x.followerId))),
|
||||
fetcher: followeeId => this.followingsRepository.findBy({ followeeId: followeeId }).then(xs => new Map(xs.map(x => [x.followerId, x]))),
|
||||
bulkFetcher: followeeIds => this.followingsRepository
|
||||
.findBy({ followeeId: In(followeeIds) })
|
||||
.then(fs => fs
|
||||
.reduce((groups, f) => {
|
||||
let group = groups.get(f.followeeId);
|
||||
if (!group) {
|
||||
group = new Map();
|
||||
groups.set(f.followeeId, group);
|
||||
}
|
||||
group.set(f.followerId, f);
|
||||
return groups;
|
||||
}, {} as Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>)),
|
||||
});
|
||||
|
||||
this.hibernatedUserCache = new QuantumKVCache<boolean>(this.internalEventService, 'hibernatedUsers', {
|
||||
lifetime: 1000 * 60 * 30, // 30m
|
||||
fetcher: async userId => {
|
||||
const { isHibernated } = await this.usersRepository.findOneOrFail({
|
||||
where: { id: userId },
|
||||
select: { isHibernated: true },
|
||||
});
|
||||
return isHibernated;
|
||||
},
|
||||
bulkFetcher: async userIds => {
|
||||
const results = await this.usersRepository.find({
|
||||
where: { id: In(userIds) },
|
||||
select: { id: true, isHibernated: true },
|
||||
});
|
||||
return results.map(({ id, isHibernated }) => [id, isHibernated]);
|
||||
},
|
||||
onChanged: async userIds => {
|
||||
// We only update local copies since each process will get this event, but we can have user objects in multiple different caches.
|
||||
// Before doing anything else we must "find" all the objects to update.
|
||||
const userObjects = new Map<string, MiUser[]>();
|
||||
const toUpdate: string[] = [];
|
||||
for (const uid of userIds) {
|
||||
const toAdd: MiUser[] = [];
|
||||
|
||||
const localUserById = this.localUserByIdCache.get(uid);
|
||||
if (localUserById) toAdd.push(localUserById);
|
||||
|
||||
const userById = this.userByIdCache.get(uid);
|
||||
if (userById) toAdd.push(userById);
|
||||
|
||||
if (toAdd.length > 0) {
|
||||
toUpdate.push(uid);
|
||||
userObjects.set(uid, toAdd);
|
||||
}
|
||||
}
|
||||
|
||||
// In many cases, we won't have to do anything.
|
||||
// Skipping the DB fetch ensures that this remains a single-step synchronous process.
|
||||
if (toUpdate.length > 0) {
|
||||
const hibernations = await this.usersRepository.find({ where: { id: In(toUpdate) }, select: { id: true, isHibernated: true } });
|
||||
for (const { id, isHibernated } of hibernations) {
|
||||
const users = userObjects.get(id);
|
||||
if (users) {
|
||||
for (const u of users) {
|
||||
u.isHibernated = isHibernated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
this.translationsCache = new RedisKVCache<CachedTranslationEntity>(this.redisClient, 'translations', {
|
||||
|
@ -161,6 +271,7 @@ export class CacheService implements OnApplicationShutdown {
|
|||
this.renoteMutingsCache.delete(body.id),
|
||||
this.userFollowingsCache.delete(body.id),
|
||||
this.userFollowersCache.delete(body.id),
|
||||
this.hibernatedUserCache.delete(body.id),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
|
@ -312,142 +423,6 @@ export class CacheService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserFollowings(userIds: Iterable<string>): Promise<Map<string, Map<string, { withReplies: boolean }>>> {
|
||||
const followings = new Map<string, Map<string, { withReplies: boolean }>>();
|
||||
|
||||
const toFetch: string[] = [];
|
||||
for (const userId of userIds) {
|
||||
const fromCache = this.userFollowingsCache.get(userId);
|
||||
if (fromCache) {
|
||||
followings.set(userId, fromCache);
|
||||
} else {
|
||||
toFetch.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length > 0) {
|
||||
const fetchedFollowings = await this.followingsRepository
|
||||
.createQueryBuilder('following')
|
||||
.select([
|
||||
'following.followerId',
|
||||
'following.followeeId',
|
||||
'following.withReplies',
|
||||
])
|
||||
.where({
|
||||
followerId: In(toFetch),
|
||||
})
|
||||
.getMany();
|
||||
|
||||
const toCache = new Map<string, Map<string, { withReplies: boolean }>>();
|
||||
|
||||
// Pivot to a map
|
||||
for (const { followerId, followeeId, withReplies } of fetchedFollowings) {
|
||||
// Queue for cache
|
||||
let cacheMap = toCache.get(followerId);
|
||||
if (!cacheMap) {
|
||||
cacheMap = new Map();
|
||||
toCache.set(followerId, cacheMap);
|
||||
}
|
||||
cacheMap.set(followeeId, { withReplies });
|
||||
|
||||
// Queue for return
|
||||
let returnSet = followings.get(followerId);
|
||||
if (!returnSet) {
|
||||
returnSet = new Map();
|
||||
followings.set(followerId, returnSet);
|
||||
}
|
||||
returnSet.set(followeeId, { withReplies });
|
||||
}
|
||||
|
||||
// Update cache to speed up future calls
|
||||
this.userFollowingsCache.addMany(toCache);
|
||||
}
|
||||
|
||||
return followings;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getUserBlockers(userIds: Iterable<string>): Promise<Map<string, Set<string>>> {
|
||||
const blockers = new Map<string, Set<string>>();
|
||||
|
||||
const toFetch: string[] = [];
|
||||
for (const userId of userIds) {
|
||||
const fromCache = this.userBlockedCache.get(userId);
|
||||
if (fromCache) {
|
||||
blockers.set(userId, fromCache);
|
||||
} else {
|
||||
toFetch.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length > 0) {
|
||||
const fetchedBlockers = await this.blockingsRepository.createQueryBuilder('blocking')
|
||||
.select([
|
||||
'blocking.blockerId',
|
||||
'blocking.blockeeId',
|
||||
])
|
||||
.where({
|
||||
blockeeId: In(toFetch),
|
||||
})
|
||||
.getMany();
|
||||
|
||||
const toCache = new Map<string, Set<string>>();
|
||||
|
||||
// Pivot to a map
|
||||
for (const { blockerId, blockeeId } of fetchedBlockers) {
|
||||
// Queue for cache
|
||||
let cacheSet = toCache.get(blockeeId);
|
||||
if (!cacheSet) {
|
||||
cacheSet = new Set();
|
||||
toCache.set(blockeeId, cacheSet);
|
||||
}
|
||||
cacheSet.add(blockerId);
|
||||
|
||||
// Queue for return
|
||||
let returnSet = blockers.get(blockeeId);
|
||||
if (!returnSet) {
|
||||
returnSet = new Set();
|
||||
blockers.set(blockeeId, returnSet);
|
||||
}
|
||||
returnSet.add(blockerId);
|
||||
}
|
||||
|
||||
// Update cache to speed up future calls
|
||||
this.userBlockedCache.addMany(toCache);
|
||||
}
|
||||
|
||||
return blockers;
|
||||
}
|
||||
|
||||
public async getUserProfiles(userIds: Iterable<string>): Promise<Map<string, MiUserProfile>> {
|
||||
const profiles = new Map<string, MiUserProfile>;
|
||||
|
||||
const toFetch: string[] = [];
|
||||
for (const userId of userIds) {
|
||||
const fromCache = this.userProfileCache.get(userId);
|
||||
if (fromCache) {
|
||||
profiles.set(userId, fromCache);
|
||||
} else {
|
||||
toFetch.push(userId);
|
||||
}
|
||||
}
|
||||
|
||||
if (toFetch.length > 0) {
|
||||
const fetched = await this.userProfilesRepository.findBy({
|
||||
userId: In(toFetch),
|
||||
});
|
||||
|
||||
for (const profile of fetched) {
|
||||
profiles.set(profile.userId, profile);
|
||||
}
|
||||
|
||||
const toCache = new Map(fetched.map(p => [p.userId, p]));
|
||||
this.userProfileCache.addMany(toCache);
|
||||
}
|
||||
|
||||
return profiles;
|
||||
}
|
||||
|
||||
public async getUsers(userIds: Iterable<string>): Promise<Map<string, MiUser>> {
|
||||
const users = new Map<string, MiUser>;
|
||||
|
||||
|
@ -475,6 +450,61 @@ export class CacheService implements OnApplicationShutdown {
|
|||
return users;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isFollowing(follower: string | { id: string }, followee: string | { id: string }): Promise<boolean> {
|
||||
const followerId = typeof(follower) === 'string' ? follower : follower.id;
|
||||
const followeeId = typeof(followee) === 'string' ? followee : followee.id;
|
||||
|
||||
// This lets us use whichever one is in memory, falling back to DB fetch via userFollowingsCache.
|
||||
return this.userFollowersCache.get(followeeId)?.has(followerId)
|
||||
?? (await this.userFollowingsCache.fetch(followerId)).has(followeeId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all hibernated followers.
|
||||
*/
|
||||
@bindThis
|
||||
public async getHibernatedFollowers(followeeId: string): Promise<MiFollowing[]> {
|
||||
const followers = await this.getFollowersWithHibernation(followeeId);
|
||||
return followers.filter(f => f.isFollowerHibernated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all non-hibernated followers.
|
||||
*/
|
||||
@bindThis
|
||||
public async getNonHibernatedFollowers(followeeId: string): Promise<MiFollowing[]> {
|
||||
const followers = await this.getFollowersWithHibernation(followeeId);
|
||||
return followers.filter(f => !f.isFollowerHibernated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns follower relations with populated isFollowerHibernated.
|
||||
* If you don't need this field, then please use userFollowersCache directly for reduced overhead.
|
||||
*/
|
||||
@bindThis
|
||||
public async getFollowersWithHibernation(followeeId: string): Promise<MiFollowing[]> {
|
||||
const followers = await this.userFollowersCache.fetch(followeeId);
|
||||
const hibernations = await this.hibernatedUserCache.fetchMany(followers.keys()).then(fs => fs.reduce((map, f) => {
|
||||
map.set(f[0], f[1]);
|
||||
return map;
|
||||
}, new Map<string, boolean>));
|
||||
return Array.from(followers.values()).map(following => ({
|
||||
...following,
|
||||
isFollowerHibernated: hibernations.get(following.followerId) ?? false,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes follower and following relations for the given user.
|
||||
*/
|
||||
@bindThis
|
||||
public async refreshFollowRelationsFor(userId: string): Promise<void> {
|
||||
const followings = await this.userFollowingsCache.refresh(userId);
|
||||
const followees = Array.from(followings.values()).map(f => f.followeeId);
|
||||
await this.userFollowersCache.deleteMany(followees);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public clear(): void {
|
||||
this.userByIdCache.clear();
|
||||
|
|
|
@ -265,7 +265,7 @@ export interface InternalEventTypes {
|
|||
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
|
||||
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
|
||||
quantumCacheUpdated: { name: string, keys: string[], op: 's' | 'd' };
|
||||
quantumCacheUpdated: { name: string, keys: string[] };
|
||||
}
|
||||
|
||||
type EventTypesToEventPayload<T> = EventUnionFromDictionary<UndefinedAsNullAll<SerializedAll<T>>>;
|
||||
|
|
|
@ -606,11 +606,11 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
if (data.reply == null) {
|
||||
// TODO: キャッシュ
|
||||
this.followingsRepository.findBy({
|
||||
followeeId: user.id,
|
||||
notify: 'normal',
|
||||
}).then(async followings => {
|
||||
this.cacheService.userFollowersCache.fetch(user.id).then(async followingsMap => {
|
||||
const followings = Array
|
||||
.from(followingsMap.values())
|
||||
.filter(f => f.notify === 'normal');
|
||||
|
||||
if (note.visibility !== 'specified') {
|
||||
const isPureRenote = this.isRenote(data) && !this.isQuote(data) ? true : false;
|
||||
for (const following of followings) {
|
||||
|
@ -948,14 +948,7 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
// TODO: キャッシュ?
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.cacheService.getNonHibernatedFollowers(user.id),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
@ -1072,17 +1065,19 @@ export class NoteCreateService implements OnApplicationShutdown {
|
|||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
await Promise.all([
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
|
||||
}),
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
});
|
||||
}),
|
||||
this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -833,14 +833,7 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
// TODO: キャッシュ?
|
||||
// eslint-disable-next-line prefer-const
|
||||
let [followings, userListMemberships] = await Promise.all([
|
||||
this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerHost: IsNull(),
|
||||
isFollowerHibernated: false,
|
||||
},
|
||||
select: ['followerId', 'withReplies'],
|
||||
}),
|
||||
this.cacheService.getNonHibernatedFollowers(user.id),
|
||||
this.userListMembershipsRepository.find({
|
||||
where: {
|
||||
userId: user.id,
|
||||
|
@ -957,17 +950,19 @@ export class NoteEditService implements OnApplicationShutdown {
|
|||
});
|
||||
|
||||
if (hibernatedUsers.length > 0) {
|
||||
await Promise.all([
|
||||
this.usersRepository.update({
|
||||
id: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isHibernated: true,
|
||||
});
|
||||
|
||||
}),
|
||||
this.followingsRepository.update({
|
||||
followerId: In(hibernatedUsers.map(x => x.id)),
|
||||
}, {
|
||||
isFollowerHibernated: true,
|
||||
});
|
||||
}),
|
||||
this.cacheService.hibernatedUserCache.setMany(hibernatedUsers.map(x => [x.id, true])),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -147,12 +147,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (blocked) throw new IdentifiableError('3338392a-f764-498d-8855-db939dcf8c48', 'blocked');
|
||||
}
|
||||
|
||||
if (await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
})) {
|
||||
if (await this.cacheService.isFollowing(follower, followee)) {
|
||||
// すでにフォロー関係が存在している場合
|
||||
if (this.userEntityService.isRemoteUser(follower) && this.userEntityService.isLocalUser(followee)) {
|
||||
// リモート → ローカル: acceptを送り返しておしまい
|
||||
|
@ -180,24 +175,14 @@ export class UserFollowingService implements OnModuleInit {
|
|||
let autoAccept = false;
|
||||
|
||||
// 鍵アカウントであっても、既にフォローされていた場合はスルー
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.isFollowing(follower, followee);
|
||||
if (isFollowing) {
|
||||
autoAccept = true;
|
||||
}
|
||||
|
||||
// フォローしているユーザーは自動承認オプション
|
||||
if (!autoAccept && (this.userEntityService.isLocalUser(followee) && followeeProfile.autoAcceptFollowed)) {
|
||||
const isFollowed = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: followee.id,
|
||||
followeeId: follower.id,
|
||||
},
|
||||
});
|
||||
const isFollowed = await this.cacheService.isFollowing(followee, follower); // intentionally reversed parameters
|
||||
|
||||
if (isFollowed) autoAccept = true;
|
||||
}
|
||||
|
@ -206,12 +191,7 @@ export class UserFollowingService implements OnModuleInit {
|
|||
if (followee.isLocked && !autoAccept) {
|
||||
autoAccept = !!(await this.accountMoveService.validateAlsoKnownAs(
|
||||
follower,
|
||||
(oldSrc, newSrc) => this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: followee.id,
|
||||
followerId: newSrc.id,
|
||||
},
|
||||
}),
|
||||
(oldSrc, newSrc) => this.cacheService.isFollowing(newSrc, followee),
|
||||
true,
|
||||
));
|
||||
}
|
||||
|
@ -366,32 +346,29 @@ export class UserFollowingService implements OnModuleInit {
|
|||
},
|
||||
silent = false,
|
||||
): Promise<void> {
|
||||
const following = await this.followingsRepository.findOne({
|
||||
relations: {
|
||||
follower: true,
|
||||
followee: true,
|
||||
},
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const [
|
||||
followerUser,
|
||||
followeeUser,
|
||||
following,
|
||||
] = await Promise.all([
|
||||
this.cacheService.findUserById(follower.id),
|
||||
this.cacheService.findUserById(followee.id),
|
||||
this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)),
|
||||
]);
|
||||
|
||||
if (following === null || !following.follower || !following.followee) {
|
||||
if (following == null) {
|
||||
this.logger.warn('フォロー解除がリクエストされましたがフォローしていませんでした');
|
||||
return;
|
||||
}
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
// Handled by CacheService
|
||||
// this.cacheService.userFollowingsCache.refresh(follower.id);
|
||||
|
||||
this.decrementFollowing(following.follower, following.followee);
|
||||
this.decrementFollowing(followerUser, followeeUser);
|
||||
|
||||
if (!silent && this.userEntityService.isLocalUser(follower)) {
|
||||
// Publish unfollow event
|
||||
this.userEntityService.pack(followee.id, follower, {
|
||||
this.userEntityService.pack(followeeUser, follower, {
|
||||
schema: 'UserDetailedNotMe',
|
||||
}).then(async packed => {
|
||||
this.globalEventService.publishMainStream(follower.id, 'unfollow', packed);
|
||||
|
@ -416,8 +393,6 @@ export class UserFollowingService implements OnModuleInit {
|
|||
follower: MiUser,
|
||||
followee: MiUser,
|
||||
): Promise<void> {
|
||||
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
// Neither followee nor follower has moved.
|
||||
if (!follower.movedToUri && !followee.movedToUri) {
|
||||
//#region Decrement following / followers counts
|
||||
|
@ -691,22 +666,22 @@ export class UserFollowingService implements OnModuleInit {
|
|||
*/
|
||||
@bindThis
|
||||
private async removeFollow(followee: Both, follower: Both): Promise<void> {
|
||||
const following = await this.followingsRepository.findOne({
|
||||
relations: {
|
||||
followee: true,
|
||||
follower: true,
|
||||
},
|
||||
where: {
|
||||
followeeId: followee.id,
|
||||
followerId: follower.id,
|
||||
},
|
||||
});
|
||||
const [
|
||||
followerUser,
|
||||
followeeUser,
|
||||
following,
|
||||
] = await Promise.all([
|
||||
this.cacheService.findUserById(follower.id),
|
||||
this.cacheService.findUserById(followee.id),
|
||||
this.cacheService.userFollowingsCache.fetch(follower.id).then(fs => fs.get(followee.id)),
|
||||
]);
|
||||
|
||||
if (!following || !following.followee || !following.follower) return;
|
||||
if (!following) return;
|
||||
|
||||
await this.followingsRepository.delete(following.id);
|
||||
await this.internalEventService.emit('unfollow', { followerId: follower.id, followeeId: followee.id });
|
||||
|
||||
this.decrementFollowing(following.follower, following.followee);
|
||||
this.decrementFollowing(followerUser, followeeUser);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -737,36 +712,26 @@ export class UserFollowingService implements OnModuleInit {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public getFollowees(userId: MiUser['id']) {
|
||||
return this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: userId })
|
||||
.getMany();
|
||||
public async getFollowees(userId: MiUser['id']) {
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(userId);
|
||||
return Array.from(followings.values());
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
||||
return this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId,
|
||||
followeeId,
|
||||
},
|
||||
});
|
||||
public async isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
|
||||
return this.cacheService.isFollowing(followerId, followeeId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
|
||||
const count = await this.followingsRepository.createQueryBuilder('following')
|
||||
.where(new Brackets(qb => {
|
||||
qb.where('following.followerId = :aUserId', { aUserId })
|
||||
.andWhere('following.followeeId = :bUserId', { bUserId });
|
||||
}))
|
||||
.orWhere(new Brackets(qb => {
|
||||
qb.where('following.followerId = :bUserId', { bUserId })
|
||||
.andWhere('following.followeeId = :aUserId', { aUserId });
|
||||
}))
|
||||
.getCount();
|
||||
const [
|
||||
isFollowing,
|
||||
isFollowed,
|
||||
] = await Promise.all([
|
||||
this.isFollowing(aUserId, bUserId),
|
||||
this.isFollowing(bUserId, aUserId),
|
||||
]);
|
||||
|
||||
return count === 2;
|
||||
return isFollowing && isFollowed;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,6 +10,7 @@ import { DI } from '@/di-symbols.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import { SystemWebhookService } from '@/core/SystemWebhookService.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserService {
|
||||
|
@ -20,6 +21,7 @@ export class UserService {
|
|||
private followingsRepository: FollowingsRepository,
|
||||
private systemWebhookService: SystemWebhookService,
|
||||
private userEntityService: UserEntityService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -38,14 +40,17 @@ export class UserService {
|
|||
});
|
||||
const wokeUp = result.isHibernated;
|
||||
if (wokeUp) {
|
||||
await Promise.all([
|
||||
this.usersRepository.update(user.id, {
|
||||
isHibernated: false,
|
||||
});
|
||||
}),
|
||||
this.followingsRepository.update({
|
||||
followerId: user.id,
|
||||
}, {
|
||||
isFollowerHibernated: false,
|
||||
});
|
||||
}),
|
||||
this.cacheService.hibernatedUserCache.set(user.id, false),
|
||||
]);
|
||||
}
|
||||
} else {
|
||||
this.usersRepository.update(user.id, {
|
||||
|
|
|
@ -16,6 +16,7 @@ import { bindThis } from '@/decorators.js';
|
|||
import { RelationshipJobData } from '@/queue/types.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
import { isSystemAccount } from '@/misc/is-system-account.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
@Injectable()
|
||||
export class UserSuspendService {
|
||||
|
@ -34,6 +35,7 @@ export class UserSuspendService {
|
|||
private globalEventService: GlobalEventService,
|
||||
private apRendererService: ApRendererService,
|
||||
private moderationLogService: ModerationLogService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -143,12 +145,8 @@ export class UserSuspendService {
|
|||
|
||||
@bindThis
|
||||
private async unFollowAll(follower: MiUser) {
|
||||
const followings = await this.followingsRepository.find({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: Not(IsNull()),
|
||||
},
|
||||
});
|
||||
const followings = await this.cacheService.userFollowingsCache.fetch(follower.id)
|
||||
.then(fs => Array.from(fs.values()).filter(f => f.followeeHost != null));
|
||||
|
||||
const jobs: RelationshipJobData[] = [];
|
||||
for (const following of followings) {
|
||||
|
|
|
@ -5,7 +5,6 @@
|
|||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { IsNull, Not } from 'typeorm';
|
||||
import { UnrecoverableError } from 'bullmq';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { FollowingsRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser, MiRemoteUser, MiUser } from '@/models/User.js';
|
||||
|
@ -14,6 +13,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { bindThis } from '@/decorators.js';
|
||||
import type { IActivity } from '@/core/activitypub/type.js';
|
||||
import { ThinUser } from '@/queue/types.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
interface IRecipe {
|
||||
type: string;
|
||||
|
@ -41,16 +41,14 @@ class DeliverManager {
|
|||
|
||||
/**
|
||||
* Constructor
|
||||
* @param userEntityService
|
||||
* @param followingsRepository
|
||||
* @param queueService
|
||||
* @param cacheService
|
||||
* @param actor Actor
|
||||
* @param activity Activity to deliver
|
||||
*/
|
||||
constructor(
|
||||
private userEntityService: UserEntityService,
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private queueService: QueueService,
|
||||
private readonly cacheService: CacheService,
|
||||
|
||||
actor: { id: MiUser['id']; host: null; },
|
||||
activity: IActivity | null,
|
||||
|
@ -114,24 +112,23 @@ class DeliverManager {
|
|||
// Process follower recipes first to avoid duplication when processing direct recipes later.
|
||||
if (this.recipes.some(r => isFollowers(r))) {
|
||||
// followers deliver
|
||||
// TODO: SELECT DISTINCT ON ("followerSharedInbox") "followerSharedInbox" みたいな問い合わせにすればよりパフォーマンス向上できそう
|
||||
// ただ、sharedInboxがnullなリモートユーザーも稀におり、その対応ができなさそう?
|
||||
const followers = await this.followingsRepository.find({
|
||||
where: {
|
||||
followeeId: this.actor.id,
|
||||
followerHost: Not(IsNull()),
|
||||
},
|
||||
select: {
|
||||
followerSharedInbox: true,
|
||||
followerInbox: true,
|
||||
followerId: true,
|
||||
},
|
||||
});
|
||||
const followers = await this.cacheService.userFollowingsCache
|
||||
.fetch(this.actor.id)
|
||||
.then(f => Array
|
||||
.from(f.values())
|
||||
.filter(f => f.followerHost != null)
|
||||
.map(f => ({
|
||||
followerInbox: f.followerInbox,
|
||||
followerSharedInbox: f.followerSharedInbox,
|
||||
})));
|
||||
|
||||
for (const following of followers) {
|
||||
const inbox = following.followerSharedInbox ?? following.followerInbox;
|
||||
if (inbox === null) throw new UnrecoverableError(`deliver failed for ${this.actor.id}: follower ${following.followerId} inbox is null`);
|
||||
inboxes.set(inbox, following.followerSharedInbox != null);
|
||||
if (following.followerSharedInbox) {
|
||||
inboxes.set(following.followerSharedInbox, true);
|
||||
} else if (following.followerInbox) {
|
||||
inboxes.set(following.followerInbox, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -153,11 +150,8 @@ class DeliverManager {
|
|||
@Injectable()
|
||||
export class ApDeliverManagerService {
|
||||
constructor(
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private queueService: QueueService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
}
|
||||
|
||||
|
@ -169,9 +163,8 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToFollowers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -188,9 +181,8 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUser(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, to: MiRemoteUser): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -207,9 +199,8 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public async deliverToUsers(actor: { id: MiLocalUser['id']; host: null; }, activity: IActivity, targets: MiRemoteUser[]): Promise<void> {
|
||||
const manager = new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
actor,
|
||||
activity,
|
||||
);
|
||||
|
@ -220,9 +211,8 @@ export class ApDeliverManagerService {
|
|||
@bindThis
|
||||
public createDeliverManager(actor: { id: MiUser['id']; host: null; }, activity: IActivity | null): DeliverManager {
|
||||
return new DeliverManager(
|
||||
this.userEntityService,
|
||||
this.followingsRepository,
|
||||
this.queueService,
|
||||
this.cacheService,
|
||||
|
||||
actor,
|
||||
activity,
|
||||
|
|
|
@ -37,6 +37,7 @@ import InstanceChart from '@/core/chart/charts/instance.js';
|
|||
import FederationChart from '@/core/chart/charts/federation.js';
|
||||
import { FetchInstanceMetadataService } from '@/core/FetchInstanceMetadataService.js';
|
||||
import { UpdateInstanceQueue } from '@/core/UpdateInstanceQueue.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { getApHrefNullable, getApId, getApIds, getApType, getNullableApId, isAccept, isActor, isAdd, isAnnounce, isApObject, isBlock, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isDislike, isMove, isPost, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost, isActivity, IObjectWithId } from './type.js';
|
||||
import { ApNoteService } from './models/ApNoteService.js';
|
||||
import { ApLoggerService } from './ApLoggerService.js';
|
||||
|
@ -98,6 +99,7 @@ export class ApInboxService {
|
|||
private readonly instanceChart: InstanceChart,
|
||||
private readonly federationChart: FederationChart,
|
||||
private readonly updateInstanceQueue: UpdateInstanceQueue,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
this.logger = this.apLoggerService.logger;
|
||||
}
|
||||
|
@ -766,12 +768,7 @@ export class ApInboxService {
|
|||
return 'skip: follower not found';
|
||||
}
|
||||
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: actor.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(actor.id));
|
||||
|
||||
if (isFollowing) {
|
||||
await this.userFollowingService.unfollow(follower, actor);
|
||||
|
@ -830,12 +827,7 @@ export class ApInboxService {
|
|||
},
|
||||
});
|
||||
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: actor.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(actor.id).then(f => f.has(followee.id));
|
||||
|
||||
if (requestExist) {
|
||||
await this.userFollowingService.cancelFollowRequest(followee, actor);
|
||||
|
|
|
@ -741,11 +741,18 @@ export class ApPersonService implements OnModuleInit, OnApplicationShutdown {
|
|||
this.hashtagService.updateUsertags(exist, tags);
|
||||
|
||||
// 該当ユーザーが既にフォロワーになっていた場合はFollowingもアップデートする
|
||||
if (exist.inbox !== person.inbox || exist.sharedInbox !== (person.sharedInbox ?? person.endpoints?.sharedInbox)) {
|
||||
await this.followingsRepository.update(
|
||||
{ followerId: exist.id },
|
||||
{ followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null },
|
||||
{
|
||||
followerInbox: person.inbox,
|
||||
followerSharedInbox: person.sharedInbox ?? person.endpoints?.sharedInbox ?? null,
|
||||
},
|
||||
);
|
||||
|
||||
await this.cacheService.refreshFollowRelationsFor(exist.id);
|
||||
}
|
||||
|
||||
await this.updateFeatured(exist.id, resolver).catch(err => {
|
||||
// Permanent error implies hidden or inaccessible, which is a normal thing.
|
||||
if (isRetryableError(err)) {
|
||||
|
|
|
@ -44,6 +44,7 @@ export default class FederationChart extends Chart<typeof schema> { // eslint-di
|
|||
}
|
||||
|
||||
protected async tickMinor(): Promise<Partial<KVs<typeof schema>>> {
|
||||
// TODO optimization: replace these with exists()
|
||||
const pubsubSubQuery = this.followingsRepository.createQueryBuilder('f')
|
||||
.select('f.followerHost')
|
||||
.where('f.followerHost IS NOT NULL');
|
||||
|
|
|
@ -15,6 +15,7 @@ import Chart from '../core.js';
|
|||
import { ChartLoggerService } from '../ChartLoggerService.js';
|
||||
import { name, schema } from './entities/per-user-following.js';
|
||||
import type { KVs } from '../core.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
/**
|
||||
* ユーザーごとのフォローに関するチャート
|
||||
|
@ -31,23 +32,25 @@ export default class PerUserFollowingChart extends Chart<typeof schema> { // esl
|
|||
private appLockService: AppLockService,
|
||||
private userEntityService: UserEntityService,
|
||||
private chartLoggerService: ChartLoggerService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(db, (k) => appLockService.getChartInsertLock(k), chartLoggerService.logger, name, schema, true);
|
||||
}
|
||||
|
||||
protected async tickMajor(group: string): Promise<Partial<KVs<typeof schema>>> {
|
||||
const [
|
||||
localFollowingsCount,
|
||||
localFollowersCount,
|
||||
remoteFollowingsCount,
|
||||
remoteFollowersCount,
|
||||
followees,
|
||||
followers,
|
||||
] = await Promise.all([
|
||||
this.followingsRepository.countBy({ followerId: group, followeeHost: IsNull() }),
|
||||
this.followingsRepository.countBy({ followeeId: group, followerHost: IsNull() }),
|
||||
this.followingsRepository.countBy({ followerId: group, followeeHost: Not(IsNull()) }),
|
||||
this.followingsRepository.countBy({ followeeId: group, followerHost: Not(IsNull()) }),
|
||||
this.cacheService.userFollowingsCache.fetch(group).then(fs => Array.from(fs.values())),
|
||||
this.cacheService.userFollowersCache.fetch(group).then(fs => Array.from(fs.values())),
|
||||
]);
|
||||
|
||||
const localFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 1 : 0), 0);
|
||||
const localFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 1 : 0), 0);
|
||||
const remoteFollowingsCount = followees.reduce((sum, f) => sum + (f.followeeHost == null ? 0 : 1), 0);
|
||||
const remoteFollowersCount = followers.reduce((sum, f) => sum + (f.followerHost == null ? 0 : 1), 0);
|
||||
|
||||
return {
|
||||
'local.followings.total': localFollowingsCount,
|
||||
'local.followers.total': localFollowersCount,
|
||||
|
|
|
@ -11,7 +11,7 @@ import type { Packed } from '@/misc/json-schema.js';
|
|||
import { awaitAll } from '@/misc/prelude/await-all.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import type { MiNote } from '@/models/Note.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel } from '@/models/_.js';
|
||||
import type { UsersRepository, NotesRepository, FollowingsRepository, PollsRepository, PollVotesRepository, NoteReactionsRepository, ChannelsRepository, MiMeta, MiPollVote, MiPoll, MiChannel, MiFollowing } from '@/models/_.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { DebounceLoader } from '@/misc/loader.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
|
@ -133,7 +133,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async hideNote(packedNote: Packed<'Note'>, meId: MiUser['id'] | null, hint?: {
|
||||
myFollowing?: ReadonlyMap<string, { withReplies: boolean }>,
|
||||
myFollowing?: ReadonlyMap<string, unknown>,
|
||||
myBlockers?: ReadonlySet<string>,
|
||||
}): Promise<void> {
|
||||
if (meId === packedNote.userId) return;
|
||||
|
@ -416,7 +416,7 @@ export class NoteEntityService implements OnModuleInit {
|
|||
packedFiles: Map<MiNote['fileIds'][number], Packed<'DriveFile'> | null>;
|
||||
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
|
||||
mentionHandles: Record<string, string | undefined>;
|
||||
userFollowings: Map<string, Map<string, { withReplies: boolean }>>;
|
||||
userFollowings: Map<string, Map<string, Omit<MiFollowing, 'isFollowerHibernated'>>>;
|
||||
userBlockers: Map<string, Set<string>>;
|
||||
polls: Map<string, MiPoll>;
|
||||
pollVotes: Map<string, Map<string, MiPollVote[]>>;
|
||||
|
@ -659,9 +659,9 @@ export class NoteEntityService implements OnModuleInit {
|
|||
// mentionHandles
|
||||
this.getUserHandles(Array.from(mentionedUsers)),
|
||||
// userFollowings
|
||||
this.cacheService.getUserFollowings(userIds),
|
||||
this.cacheService.userFollowingsCache.fetchMany(userIds).then(fs => new Map(fs)),
|
||||
// userBlockers
|
||||
this.cacheService.getUserBlockers(userIds),
|
||||
this.cacheService.userBlockedCache.fetchMany(userIds).then(bs => new Map(bs)),
|
||||
// polls
|
||||
this.pollsRepository.findBy({ noteId: In(noteIds) })
|
||||
.then(polls => new Map(polls.map(p => [p.noteId, p]))),
|
||||
|
|
|
@ -79,7 +79,7 @@ function isRemoteUser(user: MiUser | { host: MiUser['host'] }): boolean {
|
|||
|
||||
export type UserRelation = {
|
||||
id: MiUser['id']
|
||||
following: MiFollowing | null,
|
||||
following: Omit<MiFollowing, 'isFollowerHibernated'> | null,
|
||||
isFollowing: boolean
|
||||
isFollowed: boolean
|
||||
hasPendingFollowRequestFromYou: boolean
|
||||
|
@ -197,16 +197,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
memo,
|
||||
mutedInstances,
|
||||
] = await Promise.all([
|
||||
this.followingsRepository.findOneBy({
|
||||
followerId: me,
|
||||
followeeId: target,
|
||||
}),
|
||||
this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: target,
|
||||
followeeId: me,
|
||||
},
|
||||
}),
|
||||
this.cacheService.userFollowingsCache.fetch(me).then(f => f.get(target) ?? null),
|
||||
this.cacheService.userFollowingsCache.fetch(target).then(f => f.has(me)),
|
||||
this.followRequestsRepository.exists({
|
||||
where: {
|
||||
followerId: me,
|
||||
|
@ -227,8 +219,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
.then(mutings => mutings.has(target)),
|
||||
this.cacheService.renoteMutingsCache.fetch(me)
|
||||
.then(mutings => mutings.has(target)),
|
||||
this.cacheService.userByIdCache.fetch(target, () => this.usersRepository.findOneByOrFail({ id: target }))
|
||||
.then(user => user.host),
|
||||
this.cacheService.findUserById(target).then(u => u.host),
|
||||
this.userMemosRepository.createQueryBuilder('m')
|
||||
.select('m.memo')
|
||||
.where({ userId: me, targetUserId: target })
|
||||
|
@ -271,13 +262,8 @@ export class UserEntityService implements OnModuleInit {
|
|||
memos,
|
||||
mutedInstances,
|
||||
] = await Promise.all([
|
||||
this.followingsRepository.findBy({ followerId: me })
|
||||
.then(f => new Map(f.map(it => [it.followeeId, it]))),
|
||||
this.followingsRepository.createQueryBuilder('f')
|
||||
.select('f.followerId')
|
||||
.where('f.followeeId = :me', { me })
|
||||
.getRawMany<{ f_followerId: string }>()
|
||||
.then(it => it.map(it => it.f_followerId)),
|
||||
this.cacheService.userFollowingsCache.fetch(me),
|
||||
this.cacheService.userFollowersCache.fetch(me),
|
||||
this.followRequestsRepository.createQueryBuilder('f')
|
||||
.select('f.followeeId')
|
||||
.where('f.followerId = :me', { me })
|
||||
|
@ -322,7 +308,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
id: target,
|
||||
following: following,
|
||||
isFollowing: following != null,
|
||||
isFollowed: followees.includes(target),
|
||||
isFollowed: followees.has(target),
|
||||
hasPendingFollowRequestFromYou: followersRequests.includes(target),
|
||||
hasPendingFollowRequestToYou: followeesRequests.includes(target),
|
||||
isBlocking: blockees.has(target),
|
||||
|
@ -354,7 +340,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
return false; // TODO
|
||||
}
|
||||
|
||||
// TODO make redis calls in MULTI?
|
||||
// TODO optimization: make redis calls in MULTI
|
||||
@bindThis
|
||||
public async getNotificationsInfo(userId: MiUser['id']): Promise<{
|
||||
hasUnread: boolean;
|
||||
|
@ -789,11 +775,11 @@ export class UserEntityService implements OnModuleInit {
|
|||
.map(user => user.host)
|
||||
.filter((host): host is string => host != null));
|
||||
|
||||
const _profilesFromUsers: MiUserProfile[] = [];
|
||||
const _profilesFromUsers: [string, MiUserProfile][] = [];
|
||||
const _profilesToFetch: string[] = [];
|
||||
for (const user of _users) {
|
||||
if (user.userProfile) {
|
||||
_profilesFromUsers.push(user.userProfile);
|
||||
_profilesFromUsers.push([user.id, user.userProfile]);
|
||||
} else {
|
||||
_profilesToFetch.push(user.id);
|
||||
}
|
||||
|
@ -803,13 +789,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
const [profilesMap, userMemos, userRelations, pinNotes, userIdsByUri, instances, securityKeyCounts, pendingReceivedFollows, pendingSentFollows] = await Promise.all([
|
||||
// profilesMap
|
||||
this.cacheService.getUserProfiles(_profilesToFetch)
|
||||
.then(profiles => {
|
||||
for (const profile of _profilesFromUsers) {
|
||||
profiles.set(profile.userId, profile);
|
||||
}
|
||||
return profiles;
|
||||
}),
|
||||
this.cacheService.userProfileCache.fetchMany(_profilesToFetch).then(profiles => new Map(profiles.concat(_profilesFromUsers))),
|
||||
// userMemos
|
||||
isDetailed && meId ? this.userMemosRepository.findBy({ userId: meId })
|
||||
.then(memos => new Map(memos.map(memo => [memo.targetUserId, memo.memo]))) : new Map(),
|
||||
|
@ -857,7 +837,7 @@ export class UserEntityService implements OnModuleInit {
|
|||
.groupBy('key.userId')
|
||||
.getRawMany<{ userId: string, userCount: number }>()
|
||||
.then(counts => new Map(counts.map(c => [c.userId, c.userCount]))) : new Map(),
|
||||
// TODO check query performance
|
||||
// TODO optimization: cache follow requests
|
||||
// pendingReceivedFollows
|
||||
isDetailedAndMe ? this.followRequestsRepository.createQueryBuilder('req')
|
||||
.select('req.followeeId', 'followeeId')
|
||||
|
|
|
@ -21,18 +21,18 @@ export interface QuantumKVOpts<T> {
|
|||
fetcher: (key: string, cache: QuantumKVCache<T>) => T | Promise<T>;
|
||||
|
||||
/**
|
||||
* Optional callback when a value is created or changed in the cache, either locally or elsewhere in the cluster.
|
||||
* This is called *after* the cache state is updated.
|
||||
* Optional callback to fetch the value for multiple keys that weren't found in the cache.
|
||||
* May be synchronous or async.
|
||||
* If not provided, then the implementation will fall back on repeated calls to fetcher().
|
||||
*/
|
||||
onSet?: (key: string, cache: QuantumKVCache<T>) => void | Promise<void>;
|
||||
bulkFetcher?: (keys: string[], cache: QuantumKVCache<T>) => Iterable<[key: string, value: T]> | Promise<Iterable<[key: string, value: T]>>;
|
||||
|
||||
/**
|
||||
* Optional callback when a value is deleted from the cache, either locally or elsewhere in the cluster.
|
||||
* Optional callback when one or more values are changed (created, updated, or deleted) in the cache, either locally or elsewhere in the cluster.
|
||||
* This is called *after* the cache state is updated.
|
||||
* May be synchronous or async.
|
||||
* Implementations may be synchronous or async.
|
||||
*/
|
||||
onDelete?: (key: string, cache: QuantumKVCache<T>) => void | Promise<void>;
|
||||
onChanged?: (keys: string[], cache: QuantumKVCache<T>) => void | Promise<void>;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -44,8 +44,8 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
private readonly memoryCache: MemoryKVCache<T>;
|
||||
|
||||
public readonly fetcher: QuantumKVOpts<T>['fetcher'];
|
||||
public readonly onSet: QuantumKVOpts<T>['onSet'];
|
||||
public readonly onDelete: QuantumKVOpts<T>['onDelete'];
|
||||
public readonly bulkFetcher: QuantumKVOpts<T>['bulkFetcher'];
|
||||
public readonly onChanged: QuantumKVOpts<T>['onChanged'];
|
||||
|
||||
/**
|
||||
* @param internalEventService Service bus to synchronize events.
|
||||
|
@ -59,8 +59,8 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
) {
|
||||
this.memoryCache = new MemoryKVCache(opts.lifetime);
|
||||
this.fetcher = opts.fetcher;
|
||||
this.onSet = opts.onSet;
|
||||
this.onDelete = opts.onDelete;
|
||||
this.bulkFetcher = opts.bulkFetcher;
|
||||
this.onChanged = opts.onChanged;
|
||||
|
||||
this.internalEventService.on('quantumCacheUpdated', this.onQuantumCacheUpdated, {
|
||||
// Ignore our own events, otherwise we'll immediately erase any set value.
|
||||
|
@ -122,10 +122,10 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
|
||||
this.memoryCache.set(key, value);
|
||||
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: [key] });
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] });
|
||||
|
||||
if (this.onSet) {
|
||||
await this.onSet(key, this);
|
||||
if (this.onChanged) {
|
||||
await this.onChanged([key], this);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -146,12 +146,10 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
}
|
||||
|
||||
if (changedKeys.length > 0) {
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 's', keys: changedKeys });
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: changedKeys });
|
||||
|
||||
if (this.onSet) {
|
||||
for (const key of changedKeys) {
|
||||
await this.onSet(key, this);
|
||||
}
|
||||
if (this.onChanged) {
|
||||
await this.onChanged(changedKeys, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -180,12 +178,26 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
|
||||
/**
|
||||
* Gets a value from the local memory cache, or returns undefined if not found.
|
||||
* Returns cached data only - does not make any fetches.
|
||||
*/
|
||||
@bindThis
|
||||
public get(key: string): T | undefined {
|
||||
return this.memoryCache.get(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets multiple values from the local memory cache; returning undefined for any missing keys.
|
||||
* Returns cached data only - does not make any fetches.
|
||||
*/
|
||||
@bindThis
|
||||
public getMany(keys: Iterable<string>): [key: string, value: T | undefined][] {
|
||||
const results: [key: string, value: T | undefined][] = [];
|
||||
for (const key of keys) {
|
||||
results.push([key, this.get(key)]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or fetches a value from the cache.
|
||||
* Fires an onSet event, but does not emit an update event to other processes.
|
||||
|
@ -197,13 +209,49 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
value = await this.fetcher(key, this);
|
||||
this.memoryCache.set(key, value);
|
||||
|
||||
if (this.onSet) {
|
||||
await this.onSet(key, this);
|
||||
if (this.onChanged) {
|
||||
await this.onChanged([key], this);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or fetches multiple values from the cache.
|
||||
* Fires onSet events, but does not emit any update events to other processes.
|
||||
*/
|
||||
@bindThis
|
||||
public async fetchMany(keys: Iterable<string>): Promise<[key: string, value: T][]> {
|
||||
const results: [key: string, value: T][] = [];
|
||||
const toFetch: string[] = [];
|
||||
|
||||
// Spliterate into cached results / uncached keys.
|
||||
for (const key of keys) {
|
||||
const fromCache = this.get(key);
|
||||
if (fromCache) {
|
||||
results.push([key, fromCache]);
|
||||
} else {
|
||||
toFetch.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch any uncached keys
|
||||
if (toFetch.length > 0) {
|
||||
const fetched = await this.bulkFetch(toFetch);
|
||||
|
||||
// Add to cache and return set
|
||||
this.addMany(fetched);
|
||||
results.push(...fetched);
|
||||
|
||||
// Emit event
|
||||
if (this.onChanged) {
|
||||
await this.onChanged(toFetch, this);
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true is a key exists in memory.
|
||||
* This applies to the local subset view, not the cross-cluster cache state.
|
||||
|
@ -221,10 +269,10 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
public async delete(key: string): Promise<void> {
|
||||
this.memoryCache.delete(key);
|
||||
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys: [key] });
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: [key] });
|
||||
|
||||
if (this.onDelete) {
|
||||
await this.onDelete(key, this);
|
||||
if (this.onChanged) {
|
||||
await this.onChanged([key], this);
|
||||
}
|
||||
}
|
||||
/**
|
||||
|
@ -233,21 +281,22 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
* Skips if the input is empty.
|
||||
*/
|
||||
@bindThis
|
||||
public async deleteMany(keys: string[]): Promise<void> {
|
||||
if (keys.length === 0) {
|
||||
return;
|
||||
}
|
||||
public async deleteMany(keys: Iterable<string>): Promise<void> {
|
||||
const deleted: string[] = [];
|
||||
|
||||
for (const key of keys) {
|
||||
this.memoryCache.delete(key);
|
||||
deleted.push(key);
|
||||
}
|
||||
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, op: 'd', keys });
|
||||
|
||||
if (this.onDelete) {
|
||||
for (const key of keys) {
|
||||
await this.onDelete(key, this);
|
||||
if (deleted.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.internalEventService.emit('quantumCacheUpdated', { name: this.name, keys: deleted });
|
||||
|
||||
if (this.onChanged) {
|
||||
await this.onChanged(deleted, this);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -262,6 +311,13 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
return value;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async refreshMany(keys: Iterable<string>): Promise<[key: string, value: T][]> {
|
||||
const values = await this.bulkFetch(keys);
|
||||
await this.setMany(values);
|
||||
return values;
|
||||
}
|
||||
|
||||
/**
|
||||
* Erases all entries from the local memory cache.
|
||||
* Does not send any events or update other processes.
|
||||
|
@ -291,19 +347,30 @@ export class QuantumKVCache<T> implements Iterable<[key: string, value: T]> {
|
|||
this.memoryCache.dispose();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async bulkFetch(keys: Iterable<string>): Promise<[key: string, value: T][]> {
|
||||
if (this.bulkFetcher) {
|
||||
const results = await this.bulkFetcher(Array.from(keys), this);
|
||||
return Array.from(results);
|
||||
}
|
||||
|
||||
const results: [key: string, value: T][] = [];
|
||||
for (const key of keys) {
|
||||
const value = await this.fetcher(key, this);
|
||||
results.push([key, value]);
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async onQuantumCacheUpdated(data: InternalEventTypes['quantumCacheUpdated']): Promise<void> {
|
||||
if (data.name === this.name) {
|
||||
for (const key of data.keys) {
|
||||
this.memoryCache.delete(key);
|
||||
|
||||
if (data.op === 's' && this.onSet) {
|
||||
await this.onSet(key, this);
|
||||
}
|
||||
|
||||
if (data.op === 'd' && this.onDelete) {
|
||||
await this.onDelete(key, this);
|
||||
}
|
||||
if (this.onChanged) {
|
||||
await this.onChanged(data.keys, this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -5,8 +5,6 @@
|
|||
|
||||
import * as Redis from 'ioredis';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
import { InternalEventTypes } from '@/core/GlobalEventService.js';
|
||||
|
||||
export class RedisKVCache<T> {
|
||||
private readonly lifetime: number;
|
||||
|
@ -120,9 +118,9 @@ export class RedisKVCache<T> {
|
|||
export class RedisSingleCache<T> {
|
||||
private readonly lifetime: number;
|
||||
private readonly memoryCache: MemorySingleCache<T>;
|
||||
private readonly fetcher: () => Promise<T>;
|
||||
private readonly toRedisConverter: (value: T) => string;
|
||||
private readonly fromRedisConverter: (value: string) => T | undefined;
|
||||
public readonly fetcher: () => Promise<T>;
|
||||
public readonly toRedisConverter: (value: T) => string;
|
||||
public readonly fromRedisConverter: (value: string) => T | undefined;
|
||||
|
||||
constructor(
|
||||
private redisClient: Redis.Redis,
|
||||
|
@ -245,6 +243,16 @@ export class MemoryKVCache<T> {
|
|||
return cached.value;
|
||||
}
|
||||
|
||||
public has(key: string): boolean {
|
||||
const cached = this.cache.get(key);
|
||||
if (cached == null) return false;
|
||||
if ((Date.now() - cached.date) > this.lifetime) {
|
||||
this.cache.delete(key);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public delete(key: string): void {
|
||||
this.cache.delete(key);
|
||||
|
|
|
@ -18,6 +18,7 @@ import { SearchService } from '@/core/SearchService.js';
|
|||
import { ApLogService } from '@/core/ApLogService.js';
|
||||
import { ReactionService } from '@/core/ReactionService.js';
|
||||
import { QueueService } from '@/core/QueueService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { QueueLoggerService } from '../QueueLoggerService.js';
|
||||
import type * as Bull from 'bullmq';
|
||||
import type { DbUserDeleteJobData } from '../types.js';
|
||||
|
@ -94,6 +95,7 @@ export class DeleteAccountProcessorService {
|
|||
private searchService: SearchService,
|
||||
private reactionService: ReactionService,
|
||||
private readonly apLogService: ApLogService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
this.logger = this.queueLoggerService.logger.createSubLogger('delete-account');
|
||||
}
|
||||
|
@ -140,6 +142,22 @@ export class DeleteAccountProcessorService {
|
|||
}
|
||||
|
||||
{ // Delete user relations
|
||||
await this.cacheService.refreshFollowRelationsFor(user.id);
|
||||
await this.cacheService.userFollowingsCache.delete(user.id);
|
||||
await this.cacheService.userFollowingsCache.delete(user.id);
|
||||
await this.cacheService.userBlockingCache.delete(user.id);
|
||||
await this.cacheService.userBlockedCache.delete(user.id);
|
||||
await this.cacheService.userMutingsCache.delete(user.id);
|
||||
await this.cacheService.userMutingsCache.delete(user.id);
|
||||
await this.cacheService.hibernatedUserCache.delete(user.id);
|
||||
await this.cacheService.renoteMutingsCache.delete(user.id);
|
||||
await this.cacheService.userProfileCache.delete(user.id);
|
||||
this.cacheService.userByIdCache.delete(user.id);
|
||||
this.cacheService.localUserByIdCache.delete(user.id);
|
||||
if (user.token) {
|
||||
this.cacheService.localUserByNativeTokenCache.delete(user.token);
|
||||
}
|
||||
|
||||
await this.followingsRepository.delete({
|
||||
followerId: user.id,
|
||||
});
|
||||
|
|
|
@ -12,6 +12,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['following', 'users'],
|
||||
|
@ -69,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private userEntityService: UserEntityService,
|
||||
private getterService: GetterService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const follower = me;
|
||||
|
@ -85,12 +87,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
|
||||
// Check not following
|
||||
const exist = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
},
|
||||
});
|
||||
const exist = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(followee.id));
|
||||
|
||||
if (!exist) {
|
||||
throw new ApiError(meta.errors.notFollowing);
|
||||
|
|
|
@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -69,6 +70,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private userEntityService: UserEntityService,
|
||||
private getterService: GetterService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const followee = me;
|
||||
|
@ -85,12 +87,9 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
|
||||
// Check not following
|
||||
const exist = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.has(followee.id));
|
||||
|
||||
if (exist == null) {
|
||||
if (!isFollowing) {
|
||||
throw new ApiError(meta.errors.notFollowing);
|
||||
}
|
||||
|
||||
|
|
|
@ -12,6 +12,7 @@ import { UserFollowingService } from '@/core/UserFollowingService.js';
|
|||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['following', 'users'],
|
||||
|
@ -39,6 +40,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
constructor(
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
await this.followingsRepository.update({
|
||||
|
@ -48,6 +50,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
|
||||
});
|
||||
|
||||
await this.cacheService.refreshFollowRelationsFor(me.id);
|
||||
|
||||
return;
|
||||
});
|
||||
}
|
||||
|
|
|
@ -11,6 +11,7 @@ import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
|||
import { UserFollowingService } from '@/core/UserFollowingService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { GetterService } from '@/server/api/GetterService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -71,6 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private userEntityService: UserEntityService,
|
||||
private getterService: GetterService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const follower = me;
|
||||
|
@ -87,10 +89,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
});
|
||||
|
||||
// Check not following
|
||||
const exist = await this.followingsRepository.findOneBy({
|
||||
followerId: follower.id,
|
||||
followeeId: followee.id,
|
||||
});
|
||||
const exist = await this.cacheService.userFollowingsCache.fetch(follower.id).then(f => f.get(followee.id));
|
||||
|
||||
if (exist == null) {
|
||||
throw new ApiError(meta.errors.notFollowing);
|
||||
|
@ -103,6 +102,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
withReplies: ps.withReplies != null ? ps.withReplies : undefined,
|
||||
});
|
||||
|
||||
await this.cacheService.refreshFollowRelationsFor(follower.id);
|
||||
|
||||
return await this.userEntityService.pack(follower.id, me);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -12,6 +12,7 @@ import { FollowingEntityService } from '@/core/entities/FollowingEntityService.j
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -89,6 +90,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private followingEntityService: FollowingEntityService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const user = await this.usersRepository.findOneBy(ps.userId != null
|
||||
|
@ -110,12 +112,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me == null) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
} else if (me.id !== user.id) {
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(me.id).then(f => f.has(user.id));
|
||||
if (!isFollowing) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
}
|
||||
|
|
|
@ -13,6 +13,7 @@ import { FollowingEntityService } from '@/core/entities/FollowingEntityService.j
|
|||
import { UtilityService } from '@/core/UtilityService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { RoleService } from '@/core/RoleService.js';
|
||||
import { CacheService } from '@/core/CacheService.js';
|
||||
import { ApiError } from '../../error.js';
|
||||
|
||||
export const meta = {
|
||||
|
@ -98,6 +99,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
private followingEntityService: FollowingEntityService,
|
||||
private queryService: QueryService,
|
||||
private roleService: RoleService,
|
||||
private readonly cacheService: CacheService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
const user = await this.usersRepository.findOneBy(ps.userId != null
|
||||
|
@ -119,12 +121,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
if (me == null) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
} else if (me.id !== user.id) {
|
||||
const isFollowing = await this.followingsRepository.exists({
|
||||
where: {
|
||||
followeeId: user.id,
|
||||
followerId: me.id,
|
||||
},
|
||||
});
|
||||
const isFollowing = await this.cacheService.userFollowingsCache.fetch(me.id).then(f => f.has(user.id));
|
||||
if (!isFollowing) {
|
||||
throw new ApiError(meta.errors.forbidden);
|
||||
}
|
||||
|
|
|
@ -71,6 +71,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
|
|||
this.queryService.generateBlockQueryForUsers(query, me);
|
||||
this.queryService.generateBlockedUserQueryForNotes(query, me);
|
||||
|
||||
// TODO optimization: replace with exists()
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
|
|
|
@ -36,7 +36,7 @@ export default class Connection {
|
|||
private channels = new Map<string, Channel>();
|
||||
private subscribingNotes = new Map<string, number>();
|
||||
public userProfile: MiUserProfile | null = null;
|
||||
public following: Map<string, { withReplies: boolean }> = new Map();
|
||||
public following: Map<string, Omit<MiFollowing, 'isFollowerHibernated'>> = new Map();
|
||||
public followingChannels: Set<string> = new Set();
|
||||
public userIdsWhoMeMuting: Set<string> = new Set();
|
||||
public userIdsWhoBlockingMe: Set<string> = new Set();
|
||||
|
|
|
@ -6,13 +6,14 @@
|
|||
import * as Redis from 'ioredis';
|
||||
import { Inject } from '@nestjs/common';
|
||||
import { FakeInternalEventService } from './FakeInternalEventService.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MiUser, MiUserProfile, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { BlockingsRepository, FollowingsRepository, MiUser, MutingsRepository, RenoteMutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/_.js';
|
||||
import type { MiLocalUser } from '@/models/User.js';
|
||||
import { MemoryKVCache, MemorySingleCache, RedisKVCache, RedisSingleCache } from '@/misc/cache.js';
|
||||
import { QuantumKVCache, QuantumKVOpts } from '@/misc/QuantumKVCache.js';
|
||||
import { CacheService, CachedTranslationEntity, FollowStats } from '@/core/CacheService.js';
|
||||
import { CacheService, FollowStats } from '@/core/CacheService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { InternalEventService } from '@/core/InternalEventService.js';
|
||||
|
||||
export function noOpRedis() {
|
||||
return {
|
||||
|
@ -76,55 +77,16 @@ export class NoOpCacheService extends CacheService {
|
|||
this.localUserByNativeTokenCache = new NoOpMemoryKVCache<MiLocalUser | null>();
|
||||
this.localUserByIdCache = new NoOpMemoryKVCache<MiLocalUser>();
|
||||
this.uriPersonCache = new NoOpMemoryKVCache<MiUser | null>();
|
||||
this.userProfileCache = new NoOpQuantumKVCache<MiUserProfile>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.userProfileCache.fetcher,
|
||||
onSet: this.userProfileCache.onSet,
|
||||
onDelete: this.userProfileCache.onDelete,
|
||||
});
|
||||
this.userMutingsCache = new NoOpQuantumKVCache<Set<string>>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.userMutingsCache.fetcher,
|
||||
onSet: this.userMutingsCache.onSet,
|
||||
onDelete: this.userMutingsCache.onDelete,
|
||||
});
|
||||
this.userBlockingCache = new NoOpQuantumKVCache<Set<string>>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.userBlockingCache.fetcher,
|
||||
onSet: this.userBlockingCache.onSet,
|
||||
onDelete: this.userBlockingCache.onDelete,
|
||||
});
|
||||
this.userBlockedCache = new NoOpQuantumKVCache<Set<string>>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.userBlockedCache.fetcher,
|
||||
onSet: this.userBlockedCache.onSet,
|
||||
onDelete: this.userBlockedCache.onDelete,
|
||||
});
|
||||
this.renoteMutingsCache = new NoOpQuantumKVCache<Set<string>>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.renoteMutingsCache.fetcher,
|
||||
onSet: this.renoteMutingsCache.onSet,
|
||||
onDelete: this.renoteMutingsCache.onDelete,
|
||||
});
|
||||
this.userFollowingsCache = new NoOpQuantumKVCache<Map<string, { withReplies: boolean }>>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.userFollowingsCache.fetcher,
|
||||
onSet: this.userFollowingsCache.onSet,
|
||||
onDelete: this.userFollowingsCache.onDelete,
|
||||
});
|
||||
this.userFollowersCache = new NoOpQuantumKVCache<Set<string>>({
|
||||
internalEventService: fakeInternalEventService,
|
||||
fetcher: this.userFollowersCache.fetcher,
|
||||
onSet: this.userFollowersCache.onSet,
|
||||
onDelete: this.userFollowersCache.onDelete,
|
||||
});
|
||||
this.userProfileCache = NoOpQuantumKVCache.copy(this.userProfileCache, fakeInternalEventService);
|
||||
this.userMutingsCache = NoOpQuantumKVCache.copy(this.userMutingsCache, fakeInternalEventService);
|
||||
this.userBlockingCache = NoOpQuantumKVCache.copy(this.userBlockingCache, fakeInternalEventService);
|
||||
this.userBlockedCache = NoOpQuantumKVCache.copy(this.userBlockedCache, fakeInternalEventService);
|
||||
this.renoteMutingsCache = NoOpQuantumKVCache.copy(this.renoteMutingsCache, fakeInternalEventService);
|
||||
this.userFollowingsCache = NoOpQuantumKVCache.copy(this.userFollowingsCache, fakeInternalEventService);
|
||||
this.userFollowersCache = NoOpQuantumKVCache.copy(this.userFollowersCache, fakeInternalEventService);
|
||||
this.hibernatedUserCache = NoOpQuantumKVCache.copy(this.hibernatedUserCache, fakeInternalEventService);
|
||||
this.userFollowStatsCache = new NoOpMemoryKVCache<FollowStats>();
|
||||
this.translationsCache = new NoOpRedisKVCache<CachedTranslationEntity>({
|
||||
redis: fakeRedis,
|
||||
fetcher: this.translationsCache.fetcher,
|
||||
toRedisConverter: this.translationsCache.toRedisConverter,
|
||||
fromRedisConverter: this.translationsCache.fromRedisConverter,
|
||||
});
|
||||
this.translationsCache = NoOpRedisKVCache.copy(this.translationsCache, fakeRedis);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -159,17 +121,26 @@ export class NoOpRedisKVCache<T> extends RedisKVCache<T> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static copy<T>(cache: RedisKVCache<T>, redis?: Redis.Redis): NoOpRedisKVCache<T> {
|
||||
return new NoOpRedisKVCache<T>({
|
||||
redis,
|
||||
fetcher: cache.fetcher,
|
||||
toRedisConverter: cache.toRedisConverter,
|
||||
fromRedisConverter: cache.fromRedisConverter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NoOpRedisSingleCache<T> extends RedisSingleCache<T> {
|
||||
constructor(opts?: {
|
||||
fakeRedis?: Redis.Redis;
|
||||
redis?: Redis.Redis;
|
||||
fetcher?: RedisSingleCache<T>['fetcher'];
|
||||
toRedisConverter?: RedisSingleCache<T>['toRedisConverter'];
|
||||
fromRedisConverter?: RedisSingleCache<T>['fromRedisConverter'];
|
||||
}) {
|
||||
super(
|
||||
opts?.fakeRedis ?? noOpRedis(),
|
||||
opts?.redis ?? noOpRedis(),
|
||||
'no-op',
|
||||
{
|
||||
lifetime: -1,
|
||||
|
@ -180,24 +151,37 @@ export class NoOpRedisSingleCache<T> extends RedisSingleCache<T> {
|
|||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static copy<T>(cache: RedisSingleCache<T>, redis?: Redis.Redis): NoOpRedisSingleCache<T> {
|
||||
return new NoOpRedisSingleCache<T>({
|
||||
redis,
|
||||
fetcher: cache.fetcher,
|
||||
toRedisConverter: cache.toRedisConverter,
|
||||
fromRedisConverter: cache.fromRedisConverter,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export class NoOpQuantumKVCache<T> extends QuantumKVCache<T> {
|
||||
constructor(opts: {
|
||||
internalEventService?: FakeInternalEventService,
|
||||
fetcher: QuantumKVOpts<T>['fetcher'],
|
||||
onSet?: QuantumKVOpts<T>['onSet'],
|
||||
onDelete?: QuantumKVOpts<T>['onDelete'],
|
||||
constructor(opts: Omit<QuantumKVOpts<T>, 'lifetime'> & {
|
||||
internalEventService?: InternalEventService,
|
||||
}) {
|
||||
super(
|
||||
opts.internalEventService ?? new FakeInternalEventService(),
|
||||
'no-op',
|
||||
{
|
||||
...opts,
|
||||
lifetime: -1,
|
||||
fetcher: opts.fetcher,
|
||||
onSet: opts.onSet,
|
||||
onDelete: opts.onDelete,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
public static copy<T>(cache: QuantumKVCache<T>, internalEventService?: InternalEventService): NoOpQuantumKVCache<T> {
|
||||
return new NoOpQuantumKVCache<T>({
|
||||
internalEventService,
|
||||
fetcher: cache.fetcher,
|
||||
bulkFetcher: cache.bulkFetcher,
|
||||
onChanged: cache.onChanged,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -73,19 +73,19 @@ describe(QuantumKVCache, () => {
|
|||
|
||||
await cache.set('foo', 'bar');
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]);
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
|
||||
});
|
||||
|
||||
it('should call onSet when storing', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged when storing', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.set('foo', 'bar');
|
||||
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
|
||||
});
|
||||
|
||||
it('should not emit event when storing unchanged value', async () => {
|
||||
|
@ -97,17 +97,17 @@ describe(QuantumKVCache, () => {
|
|||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should not call onSet when storing unchanged value', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should not call onChanged when storing unchanged value', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.set('foo', 'bar');
|
||||
await cache.set('foo', 'bar');
|
||||
|
||||
expect(fakeOnSet).toHaveBeenCalledTimes(1);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should fetch an unknown value', async () => {
|
||||
|
@ -133,17 +133,17 @@ describe(QuantumKVCache, () => {
|
|||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should call onSet when fetching', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged when fetching', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
fetcher: key => `value#${key}`,
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.fetch('foo');
|
||||
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
|
||||
});
|
||||
|
||||
it('should not emit event when fetching', async () => {
|
||||
|
@ -154,7 +154,7 @@ describe(QuantumKVCache, () => {
|
|||
|
||||
await cache.fetch('foo');
|
||||
|
||||
expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]);
|
||||
expect(fakeInternalEventService._calls).not.toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
|
||||
});
|
||||
|
||||
it('should delete from memory cache', async () => {
|
||||
|
@ -167,17 +167,17 @@ describe(QuantumKVCache, () => {
|
|||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onDelete when deleting', async () => {
|
||||
const fakeOnDelete = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged when deleting', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onDelete: fakeOnDelete,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.set('foo', 'bar');
|
||||
await cache.delete('foo');
|
||||
|
||||
expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
|
||||
});
|
||||
|
||||
it('should emit event when deleting', async () => {
|
||||
|
@ -186,52 +186,52 @@ describe(QuantumKVCache, () => {
|
|||
await cache.set('foo', 'bar');
|
||||
await cache.delete('foo');
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] }]]);
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
|
||||
});
|
||||
|
||||
it('should delete when receiving set event', async () => {
|
||||
const cache = makeCache<string>({ name: 'fake' });
|
||||
await cache.set('foo', 'bar');
|
||||
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] });
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
|
||||
|
||||
const result = cache.has('foo');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onSet when receiving set event', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged when receiving set event', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] });
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
|
||||
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
|
||||
});
|
||||
|
||||
it('should delete when receiving delete event', async () => {
|
||||
const cache = makeCache<string>({ name: 'fake' });
|
||||
await cache.set('foo', 'bar');
|
||||
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] });
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
|
||||
|
||||
const result = cache.has('foo');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should call onDelete when receiving delete event', async () => {
|
||||
const fakeOnDelete = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged when receiving delete event', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onDelete: fakeOnDelete,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
await cache.set('foo', 'bar');
|
||||
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo'] });
|
||||
await fakeInternalEventService._emitRedis('quantumCacheUpdated', { name: 'fake', keys: ['foo'] });
|
||||
|
||||
expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
|
@ -269,40 +269,243 @@ describe(QuantumKVCache, () => {
|
|||
|
||||
await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo', 'alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should call onSet for each item', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged once with all items', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
|
||||
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit events only for changed items', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.set('foo', 'bar');
|
||||
fakeOnSet.mockClear();
|
||||
fakeOnChanged.mockClear();
|
||||
fakeInternalEventService._reset();
|
||||
|
||||
await cache.setMany([['foo', 'bar'], ['alpha', 'omega']]);
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMany', () => {
|
||||
it('should return empty for empty input', () => {
|
||||
const cache = makeCache();
|
||||
const result = cache.getMany([]);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return the value for all keys', () => {
|
||||
const cache = makeCache();
|
||||
cache.add('foo', 'bar');
|
||||
cache.add('alpha', 'omega');
|
||||
|
||||
const result = cache.getMany(['foo', 'alpha']);
|
||||
|
||||
expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]);
|
||||
});
|
||||
|
||||
it('should return undefined for missing keys', () => {
|
||||
const cache = makeCache();
|
||||
cache.add('foo', 'bar');
|
||||
|
||||
const result = cache.getMany(['foo', 'alpha']);
|
||||
|
||||
expect(result).toEqual([['foo', 'bar'], ['alpha', undefined]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchMany', () => {
|
||||
it('should do nothing for empty input', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.fetchMany([]);
|
||||
|
||||
expect(fakeOnChanged).not.toHaveBeenCalled();
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should return existing items', async () => {
|
||||
const cache = makeCache();
|
||||
cache.add('foo', 'bar');
|
||||
cache.add('alpha', 'omega');
|
||||
|
||||
const result = await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(result).toEqual([['foo', 'bar'], ['alpha', 'omega']]);
|
||||
});
|
||||
|
||||
it('should return existing items without events', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
cache.add('foo', 'bar');
|
||||
cache.add('alpha', 'omega');
|
||||
|
||||
await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeOnChanged).not.toHaveBeenCalled();
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should call bulkFetcher for missing items', async () => {
|
||||
const cache = makeCache({
|
||||
bulkFetcher: keys => keys.map(k => [k, `${k}#many`]),
|
||||
fetcher: key => `${key}#single`,
|
||||
});
|
||||
|
||||
const results = await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(results).toEqual([['foo', 'foo#many'], ['alpha', 'alpha#many']]);
|
||||
});
|
||||
|
||||
it('should call bulkFetcher only once', async () => {
|
||||
const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string]));
|
||||
const cache = makeCache({
|
||||
bulkFetcher: mockBulkFetcher,
|
||||
});
|
||||
|
||||
await cache.fetchMany(['foo', 'bar']);
|
||||
|
||||
expect(mockBulkFetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call fetcher when fetchMany is undefined', async () => {
|
||||
const cache = makeCache({
|
||||
fetcher: key => `${key}#single`,
|
||||
});
|
||||
|
||||
const results = await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(results).toEqual([['foo', 'foo#single'], ['alpha', 'alpha#single']]);
|
||||
});
|
||||
|
||||
it('should call onChanged', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onChanged: fakeOnChanged,
|
||||
fetcher: k => k,
|
||||
});
|
||||
|
||||
await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onChanged only for changed', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onChanged: fakeOnChanged,
|
||||
fetcher: k => k,
|
||||
});
|
||||
cache.add('foo', 'bar');
|
||||
|
||||
await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['alpha'], cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should not emit event', async () => {
|
||||
const cache = makeCache({
|
||||
fetcher: k => k,
|
||||
});
|
||||
|
||||
await cache.fetchMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshMany', () => {
|
||||
it('should do nothing for empty input', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
const result = await cache.refreshMany([]);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(fakeOnChanged).not.toHaveBeenCalled();
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should call bulkFetcher for all keys', async () => {
|
||||
const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string]));
|
||||
const cache = makeCache({
|
||||
bulkFetcher: mockBulkFetcher,
|
||||
});
|
||||
|
||||
const result = await cache.refreshMany(['foo', 'alpha']);
|
||||
|
||||
expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]);
|
||||
expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache);
|
||||
expect(mockBulkFetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should replace any existing keys', async () => {
|
||||
const mockBulkFetcher = jest.fn((keys: string[]) => keys.map(k => [k, `${k}#value`] as [string, string]));
|
||||
const cache = makeCache({
|
||||
bulkFetcher: mockBulkFetcher,
|
||||
});
|
||||
cache.add('foo', 'bar');
|
||||
|
||||
const result = await cache.refreshMany(['foo', 'alpha']);
|
||||
|
||||
expect(result).toEqual([['foo', 'foo#value'], ['alpha', 'alpha#value']]);
|
||||
expect(mockBulkFetcher).toHaveBeenCalledWith(['foo', 'alpha'], cache);
|
||||
expect(mockBulkFetcher).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should call onChanged for all keys', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
bulkFetcher: keys => keys.map(k => [k, `${k}#value`]),
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
cache.add('foo', 'bar');
|
||||
|
||||
await cache.refreshMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should emit event for all keys', async () => {
|
||||
const cache = makeCache({
|
||||
name: 'fake',
|
||||
bulkFetcher: keys => keys.map(k => [k, `${k}#value`]),
|
||||
});
|
||||
cache.add('foo', 'bar');
|
||||
|
||||
await cache.refreshMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('alpha', cache);
|
||||
expect(fakeOnSet).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -325,33 +528,33 @@ describe(QuantumKVCache, () => {
|
|||
|
||||
await cache.deleteMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 'd', keys: ['foo', 'alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo', 'alpha'] }]]);
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should call onDelete for each key', async () => {
|
||||
const fakeOnDelete = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged once with all items', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onDelete: fakeOnDelete,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.deleteMany(['foo', 'alpha']);
|
||||
|
||||
expect(fakeOnDelete).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnDelete).toHaveBeenCalledWith('alpha', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo', 'alpha'], cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should do nothing if no keys are provided', async () => {
|
||||
const fakeOnDelete = jest.fn(() => Promise.resolve());
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
onDelete: fakeOnDelete,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.deleteMany([]);
|
||||
|
||||
expect(fakeOnDelete).not.toHaveBeenCalled();
|
||||
expect(fakeOnChanged).not.toHaveBeenCalled();
|
||||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
@ -392,17 +595,17 @@ describe(QuantumKVCache, () => {
|
|||
expect(result).toBe('value#foo');
|
||||
});
|
||||
|
||||
it('should call onSet', async () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should call onChanged', async () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache<string>({
|
||||
name: 'fake',
|
||||
fetcher: key => `value#${key}`,
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
await cache.refresh('foo');
|
||||
|
||||
expect(fakeOnSet).toHaveBeenCalledWith('foo', cache);
|
||||
expect(fakeOnChanged).toHaveBeenCalledWith(['foo'], cache);
|
||||
});
|
||||
|
||||
it('should emit event', async () => {
|
||||
|
@ -413,7 +616,7 @@ describe(QuantumKVCache, () => {
|
|||
|
||||
await cache.refresh('foo');
|
||||
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', op: 's', keys: ['foo'] }]]);
|
||||
expect(fakeInternalEventService._calls).toContainEqual(['emit', ['quantumCacheUpdated', { name: 'fake', keys: ['foo'] }]]);
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -434,15 +637,15 @@ describe(QuantumKVCache, () => {
|
|||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not call onSet', () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should not call onChanged', () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
cache.add('foo', 'bar');
|
||||
|
||||
expect(fakeOnSet).not.toHaveBeenCalled();
|
||||
expect(fakeOnChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
@ -466,15 +669,15 @@ describe(QuantumKVCache, () => {
|
|||
expect(fakeInternalEventService._calls.filter(c => c[0] === 'emit')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not call onSet', () => {
|
||||
const fakeOnSet = jest.fn(() => Promise.resolve());
|
||||
it('should not call onChanged', () => {
|
||||
const fakeOnChanged = jest.fn(() => Promise.resolve());
|
||||
const cache = makeCache({
|
||||
onSet: fakeOnSet,
|
||||
onChanged: fakeOnChanged,
|
||||
});
|
||||
|
||||
cache.addMany([['foo', 'bar'], ['alpha', 'omega']]);
|
||||
|
||||
expect(fakeOnSet).not.toHaveBeenCalled();
|
||||
expect(fakeOnChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
|
Loading…
Add table
Reference in a new issue