From 6b5ee43800a8e4679f15c2ae7f1f79fbe177b950 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
Date: Wed, 18 Oct 2023 15:51:50 +0900
Subject: [PATCH] enhance(backend): improve fanout tl for stl

---
 .../api/endpoints/notes/hybrid-timeline.ts    | 138 +++++++++++++-----
 1 file changed, 103 insertions(+), 35 deletions(-)

diff --git a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
index adb0f87bba..faaeb32c79 100644
--- a/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
+++ b/packages/backend/src/server/api/endpoints/notes/hybrid-timeline.ts
@@ -5,7 +5,6 @@
 
 import { Brackets } from 'typeorm';
 import { Inject, Injectable } from '@nestjs/common';
-import * as Redis from 'ioredis';
 import type { NotesRepository, FollowingsRepository, MiNote } from '@/models/_.js';
 import { Endpoint } from '@/server/api/endpoint-base.js';
 import ActiveUsersChart from '@/core/chart/charts/active-users.js';
@@ -16,6 +15,8 @@ import { IdService } from '@/core/IdService.js';
 import { isUserRelated } from '@/misc/is-user-related.js';
 import { CacheService } from '@/core/CacheService.js';
 import { FunoutTimelineService } from '@/core/FunoutTimelineService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { UserFollowingService } from '@/core/UserFollowingService.js';
 import { ApiError } from '../../error.js';
 
 export const meta = {
@@ -63,9 +64,6 @@ export const paramDef = {
 @Injectable()
 export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
 	constructor(
-		@Inject(DI.redisForTimelines)
-		private redisForTimelines: Redis.Redis,
-
 		@Inject(DI.notesRepository)
 		private notesRepository: NotesRepository,
 
@@ -75,6 +73,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 		private idService: IdService,
 		private cacheService: CacheService,
 		private funoutTimelineService: FunoutTimelineService,
+		private queryService: QueryService,
+		private userFollowingService: UserFollowingService,
 	) {
 		super(meta, paramDef, async (ps, me) => {
 			const untilId = ps.untilId ?? (ps.untilDate ? this.idService.gen(ps.untilDate!) : null);
@@ -96,6 +96,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 			]);
 
 			let noteIds: string[];
+			let shouldFallbackToDb = false;
 
 			if (ps.withFiles) {
 				const [htlNoteIds, ltlNoteIds] = await this.funoutTimelineService.getMulti([
@@ -116,51 +117,118 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
 					'localTimeline',
 				], untilId, sinceId);
 				noteIds = Array.from(new Set([...htlNoteIds, ...ltlNoteIds]));
+				shouldFallbackToDb = htlNoteIds.length === 0;
 			}
 
 			noteIds.sort((a, b) => a > b ? -1 : 1);
 			noteIds = noteIds.slice(0, ps.limit);
 
-			if (noteIds.length === 0) {
-				return [];
-			}
+			if (!shouldFallbackToDb) {
+				const query = this.notesRepository.createQueryBuilder('note')
+					.where('note.id IN (:...noteIds)', { noteIds: noteIds })
+					.innerJoinAndSelect('note.user', 'user')
+					.leftJoinAndSelect('note.reply', 'reply')
+					.leftJoinAndSelect('note.renote', 'renote')
+					.leftJoinAndSelect('reply.user', 'replyUser')
+					.leftJoinAndSelect('renote.user', 'renoteUser')
+					.leftJoinAndSelect('note.channel', 'channel');
 
-			const query = this.notesRepository.createQueryBuilder('note')
-				.where('note.id IN (:...noteIds)', { noteIds: noteIds })
-				.innerJoinAndSelect('note.user', 'user')
-				.leftJoinAndSelect('note.reply', 'reply')
-				.leftJoinAndSelect('note.renote', 'renote')
-				.leftJoinAndSelect('reply.user', 'replyUser')
-				.leftJoinAndSelect('renote.user', 'renoteUser')
-				.leftJoinAndSelect('note.channel', 'channel');
+				let timeline = await query.getMany();
 
-			let timeline = await query.getMany();
-
-			timeline = timeline.filter(note => {
-				if (note.userId === me.id) {
-					return true;
-				}
-				if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
-				if (isUserRelated(note, userIdsWhoMeMuting)) return false;
-				if (note.renoteId) {
-					if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
-						if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
-						if (ps.withRenotes === false) return false;
+				timeline = timeline.filter(note => {
+					if (note.userId === me.id) {
+						return true;
 					}
+					if (isUserRelated(note, userIdsWhoBlockingMe)) return false;
+					if (isUserRelated(note, userIdsWhoMeMuting)) return false;
+					if (note.renoteId) {
+						if (note.text == null && note.fileIds.length === 0 && !note.hasPoll) {
+							if (isUserRelated(note, userIdsWhoMeMutingRenotes)) return false;
+							if (ps.withRenotes === false) return false;
+						}
+					}
+
+					return true;
+				});
+
+				// TODO: フィルタした結果件数が足りなかった場合の対応
+
+				timeline.sort((a, b) => a.id > b.id ? -1 : 1);
+
+				process.nextTick(() => {
+					this.activeUsersChart.read(me);
+				});
+
+				return await this.noteEntityService.packMany(timeline, me);
+			} else { // fallback to db
+				const followees = await this.userFollowingService.getFollowees(me.id);
+
+				const query = this.queryService.makePaginationQuery(this.notesRepository.createQueryBuilder('note'),
+					ps.sinceId, ps.untilId, ps.sinceDate, ps.untilDate)
+					.andWhere(new Brackets(qb => {
+						if (followees.length > 0) {
+							const meOrFolloweeIds = [me.id, ...followees.map(f => f.followeeId)];
+							qb.where('note.userId IN (:...meOrFolloweeIds)', { meOrFolloweeIds: meOrFolloweeIds });
+							qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
+						} else {
+							qb.where('note.userId = :meId', { meId: me.id });
+							qb.orWhere('(note.visibility = \'public\') AND (note.userHost IS NULL)');
+						}
+					}))
+					.innerJoinAndSelect('note.user', 'user')
+					.leftJoinAndSelect('note.reply', 'reply')
+					.leftJoinAndSelect('note.renote', 'renote')
+					.leftJoinAndSelect('reply.user', 'replyUser')
+					.leftJoinAndSelect('renote.user', 'renoteUser');
+
+				this.queryService.generateVisibilityQuery(query, me);
+				this.queryService.generateMutedUserQuery(query, me);
+				this.queryService.generateBlockedUserQuery(query, me);
+				this.queryService.generateMutedUserRenotesQueryForNotes(query, me);
+
+				if (ps.includeMyRenotes === false) {
+					query.andWhere(new Brackets(qb => {
+						qb.orWhere('note.userId != :meId', { meId: me.id });
+						qb.orWhere('note.renoteId IS NULL');
+						qb.orWhere('note.text IS NOT NULL');
+						qb.orWhere('note.fileIds != \'{}\'');
+						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+					}));
 				}
 
-				return true;
-			});
+				if (ps.includeRenotedMyNotes === false) {
+					query.andWhere(new Brackets(qb => {
+						qb.orWhere('note.renoteUserId != :meId', { meId: me.id });
+						qb.orWhere('note.renoteId IS NULL');
+						qb.orWhere('note.text IS NOT NULL');
+						qb.orWhere('note.fileIds != \'{}\'');
+						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+					}));
+				}
 
-			// TODO: フィルタした結果件数が足りなかった場合の対応
+				if (ps.includeLocalRenotes === false) {
+					query.andWhere(new Brackets(qb => {
+						qb.orWhere('note.renoteUserHost IS NOT NULL');
+						qb.orWhere('note.renoteId IS NULL');
+						qb.orWhere('note.text IS NOT NULL');
+						qb.orWhere('note.fileIds != \'{}\'');
+						qb.orWhere('0 < (SELECT COUNT(*) FROM poll WHERE poll."noteId" = note.id)');
+					}));
+				}
 
-			timeline.sort((a, b) => a.id > b.id ? -1 : 1);
+				if (ps.withFiles) {
+					query.andWhere('note.fileIds != \'{}\'');
+				}
+				//#endregion
 
-			process.nextTick(() => {
-				this.activeUsersChart.read(me);
-			});
+				const timeline = await query.limit(ps.limit).getMany();
 
-			return await this.noteEntityService.packMany(timeline, me);
+				process.nextTick(() => {
+					this.activeUsersChart.read(me);
+				});
+
+				return await this.noteEntityService.packMany(timeline, me);
+			}
 		});
 	}
 }