enhance(backend): improve hashtags/trend performance
This commit is contained in:
@@ -11,6 +11,7 @@ import { bindThis } from '@/decorators.js';
|
||||
|
||||
const GLOBAL_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 3; // 3日ごと
|
||||
const PER_USER_NOTES_RANKING_WINDOW = 1000 * 60 * 60 * 24 * 7; // 1週間ごと
|
||||
const HASHTAG_RANKING_WINDOW = 1000 * 60 * 60; // 1時間ごと
|
||||
|
||||
@Injectable()
|
||||
export class FeaturedService {
|
||||
@@ -88,6 +89,11 @@ export class FeaturedService {
|
||||
return this.updateRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, noteId, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public updateHashtagsRanking(hashtag: string, score = 1): Promise<void> {
|
||||
return this.updateRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, hashtag, score);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getGlobalNotesRanking(limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf('featuredGlobalNotesRanking', GLOBAL_NOTES_RANKING_WINDOW, limit);
|
||||
@@ -102,4 +108,9 @@ export class FeaturedService {
|
||||
public getPerUserNotesRanking(userId: MiUser['id'], limit: number): Promise<MiNote['id'][]> {
|
||||
return this.getRankingOf(`featuredPerUserNotesRanking:${userId}`, PER_USER_NOTES_RANKING_WINDOW, limit);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public getHashtagsRanking(limit: number): Promise<string[]> {
|
||||
return this.getRankingOf('featuredHashtagsRanking', HASHTAG_RANKING_WINDOW, limit);
|
||||
}
|
||||
}
|
||||
|
@@ -4,6 +4,8 @@
|
||||
*/
|
||||
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import * as Redis from 'ioredis';
|
||||
import { getLineAndCharacterOfPosition } from 'typescript';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import type { MiUser } from '@/models/User.js';
|
||||
import { normalizeForSearch } from '@/misc/normalize-for-search.js';
|
||||
@@ -12,14 +14,19 @@ import type { MiHashtag } from '@/models/Hashtag.js';
|
||||
import type { HashtagsRepository } from '@/models/_.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
import { FeaturedService } from '@/core/FeaturedService.js';
|
||||
|
||||
@Injectable()
|
||||
export class HashtagService {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis, // TODO: 専用のRedisサーバーを設定できるようにする
|
||||
|
||||
@Inject(DI.hashtagsRepository)
|
||||
private hashtagsRepository: HashtagsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private featuredService: FeaturedService,
|
||||
private idService: IdService,
|
||||
) {
|
||||
}
|
||||
@@ -46,6 +53,9 @@ export class HashtagService {
|
||||
public async updateHashtag(user: { id: MiUser['id']; host: MiUser['host']; }, tag: string, isUserAttached = false, inc = true) {
|
||||
tag = normalizeForSearch(tag);
|
||||
|
||||
// TODO: サンプリング
|
||||
this.updateHashtagsRanking(tag, user.id);
|
||||
|
||||
const index = await this.hashtagsRepository.findOneBy({ name: tag });
|
||||
|
||||
if (index == null && !inc) return;
|
||||
@@ -85,7 +95,7 @@ export class HashtagService {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
// 自分が初めてこのタグを使ったなら
|
||||
if (!index.mentionedUserIds.some(id => id === user.id)) {
|
||||
set.mentionedUserIds = () => `array_append("mentionedUserIds", '${user.id}')`;
|
||||
set.mentionedUsersCount = () => '"mentionedUsersCount" + 1';
|
||||
@@ -144,4 +154,93 @@ export class HashtagService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async updateHashtagsRanking(hashtag: string, userId: MiUser['id']): Promise<void> {
|
||||
// TODO: instance.hiddenTagsの考慮
|
||||
|
||||
// YYYYMMDDHHmm (10分間隔)
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
|
||||
const exist = await this.redisClient.sismember(`hashtagUsers:${hashtag}`, userId);
|
||||
if (exist === 1) return;
|
||||
|
||||
this.featuredService.updateHashtagsRanking(hashtag, 1);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
// TODO: これらの Set は Bloom Filter を使うようにしても良さそう
|
||||
|
||||
// チャート用
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}:${window}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}:${window}`,
|
||||
60 * 60 * 24 * 3, // 3日間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
// ユニークカウント用
|
||||
redisPipeline.sadd(`hashtagUsers:${hashtag}`, userId);
|
||||
redisPipeline.expire(`hashtagUsers:${hashtag}`,
|
||||
60 * 60, // 1時間
|
||||
'NX', // "NX -- Set expiry only when the key has no expiry" = 有効期限がないときだけ設定
|
||||
);
|
||||
|
||||
redisPipeline.exec();
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getChart(hashtag: string, range: number): Promise<number[]> {
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
const result = await redisPipeline.exec();
|
||||
|
||||
if (result == null) return [];
|
||||
|
||||
return result.map(x => x[1]) as number[];
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async getCharts(hashtags: string[], range: number): Promise<Record<string, number[]>> {
|
||||
const now = new Date();
|
||||
now.setMinutes(Math.floor(now.getMinutes() / 10) * 10, 0, 0);
|
||||
|
||||
const redisPipeline = this.redisClient.pipeline();
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
const window = `${now.getUTCFullYear()}${(now.getUTCMonth() + 1).toString().padStart(2, '0')}${now.getUTCDate().toString().padStart(2, '0')}${now.getUTCHours().toString().padStart(2, '0')}${now.getUTCMinutes().toString().padStart(2, '0')}`;
|
||||
for (const hashtag of hashtags) {
|
||||
redisPipeline.scard(`hashtagUsers:${hashtag}:${window}`);
|
||||
}
|
||||
now.setMinutes(now.getMinutes() - (i * 10), 0, 0);
|
||||
}
|
||||
|
||||
const result = await redisPipeline.exec();
|
||||
|
||||
if (result == null) return {};
|
||||
|
||||
// key is hashtag
|
||||
const charts = {} as Record<string, number[]>;
|
||||
for (const hashtag of hashtags) {
|
||||
charts[hashtag] = [];
|
||||
}
|
||||
|
||||
for (let i = 0; i < range; i++) {
|
||||
for (let j = 0; j < hashtags.length; j++) {
|
||||
charts[hashtags[j]].push(result[(i * hashtags.length) + j][1] as number);
|
||||
}
|
||||
}
|
||||
|
||||
return charts;
|
||||
}
|
||||
}
|
||||
|
Reference in New Issue
Block a user