perf(user): prefetch user partials for list endpoints (#2182)

This commit is contained in:
Hampus
2026-08-30 23:05:17 +02:00
committed by GitHub
parent 6c9afcc734
commit 9f620e8c4b
3 changed files with 220 additions and 1 deletions
@@ -1,11 +1,14 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import type {ChannelResponse} from '@fluxer/schema/src/domains/channel/ChannelSchemas';
import type {CreatePrivateChannelRequest} from '@fluxer/schema/src/domains/user/UserRequestSchemas';
import type {ChannelID, UserID} from '../../BrandedTypes';
import {mapChannelToResponse} from '../../channel/ChannelMappers';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {Channel} from '../../models/Channel';
import {getCachedUserPartialResponses} from '../UserCacheHelpers';
import type {UserChannelService} from './UserChannelService';
interface UserChannelListParams {
@@ -24,6 +27,21 @@ interface UserChannelPinParams {
channelId: ChannelID;
}
function collectDMRecipientIds(channels: Array<Channel>, currentUserId: UserID): Array<UserID> {
const recipientIds = new Set<UserID>();
for (const channel of channels) {
if (channel.guildId != null || channel.type === ChannelTypes.DM_PERSONAL_NOTES) {
continue;
}
for (const recipientId of channel.recipientIds) {
if (recipientId !== currentUserId) {
recipientIds.add(recipientId);
}
}
}
return Array.from(recipientIds);
}
export class UserChannelRequestService {
constructor(
private readonly userChannelService: UserChannelService,
@@ -32,6 +50,11 @@ export class UserChannelRequestService {
async listPrivateChannels(params: UserChannelListParams): Promise<Array<ChannelResponse>> {
const channels = await this.userChannelService.getPrivateChannels(params.userId);
await getCachedUserPartialResponses({
userIds: collectDMRecipientIds(channels, params.userId),
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return Promise.all(
channels.map((channel) =>
mapChannelToResponse({
@@ -16,7 +16,7 @@ import type {UserID} from '../../BrandedTypes';
import type {UserCacheService} from '../../infrastructure/UserCacheService';
import type {RequestCache} from '../../middleware/RequestCacheMiddleware';
import type {Relationship} from '../../models/Relationship';
import {getCachedUserPartialResponse} from '../UserCacheHelpers';
import {getCachedUserPartialResponse, getCachedUserPartialResponses} from '../UserCacheHelpers';
import {mapRelationshipToResponse} from '../UserMappers';
import type {UserChannelService} from './UserChannelService';
import type {UserRelationshipService} from './UserRelationshipService';
@@ -69,6 +69,11 @@ export class UserRelationshipRequestService {
const userPartialResolver = this.createUserPartialResolver(params.requestCache);
const inverseRelationshipResolver = this.createInverseRelationshipResolver(params.userId);
const relationships = await this.userRelationshipService.getRelationships(params.userId);
await getCachedUserPartialResponses({
userIds: relationships.map((relationship) => relationship.targetUserId),
userCacheService: this.userCacheService,
requestCache: params.requestCache,
});
return Promise.all(
relationships.map((relationship) =>
mapRelationshipToResponse({relationship, userPartialResolver, inverseRelationshipResolver}),
@@ -0,0 +1,191 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {ChannelTypes} from '@fluxer/constants/src/ChannelConstants';
import {DELETED_USER_GLOBAL_NAME, DELETED_USER_USERNAME, RelationshipTypes} from '@fluxer/constants/src/UserConstants';
import type {UserPartialResponse} from '@fluxer/schema/src/domains/user/UserResponseSchemas';
import {describe, expect, test} from 'vitest';
import {type ChannelID, createChannelID, createUserID, type UserID} from '../../BrandedTypes';
import type {ChannelRow} from '../../database/types/ChannelTypes';
import type {RelationshipRow} from '../../database/types/UserTypes';
import {UserCacheService} from '../../infrastructure/UserCacheService';
import type {IUsersServiceClient} from '../../infrastructure/UsersServiceClient';
import {createRequestCache} from '../../middleware/RequestCacheMiddleware';
import {Channel} from '../../models/Channel';
import {Relationship} from '../../models/Relationship';
import {UserChannelRequestService} from '../services/UserChannelRequestService';
import type {UserChannelService} from '../services/UserChannelService';
import {UserRelationshipRequestService} from '../services/UserRelationshipRequestService';
import type {UserRelationshipService} from '../services/UserRelationshipService';
class RecordingUsersServiceClient implements IUsersServiceClient {
readonly requests: Array<Array<UserID>> = [];
constructor(private readonly partialsById: Map<UserID, UserPartialResponse>) {}
async getUserPartialResponses(userIds: Array<UserID>): Promise<Map<UserID, UserPartialResponse>> {
this.requests.push([...userIds]);
const result = new Map<UserID, UserPartialResponse>();
for (const userId of userIds) {
const partial = this.partialsById.get(userId);
if (partial) {
result.set(userId, partial);
}
}
return result;
}
async invalidateUserCache(_userId: UserID): Promise<void> {}
}
function createPartial(userId: UserID, username: string): UserPartialResponse {
return {
id: userId.toString(),
username,
discriminator: '0001',
global_name: null,
avatar: null,
avatar_color: null,
flags: 0,
};
}
function createPartials(userIds: Array<UserID>): Map<UserID, UserPartialResponse> {
return new Map(userIds.map((userId, index) => [userId, createPartial(userId, `Partial${index}`)]));
}
function createRelationship(sourceUserId: UserID, targetUserId: UserID, shareVoiceActivity: boolean): Relationship {
return new Relationship({
source_user_id: sourceUserId,
target_user_id: targetUserId,
type: RelationshipTypes.FRIEND,
nickname: null,
since: null,
share_voice_activity: shareVoiceActivity,
version: 1,
} satisfies RelationshipRow);
}
function createPrivateChannel(channelId: ChannelID, type: number, recipientIds: Set<UserID>): Channel {
return new Channel({
channel_id: channelId,
guild_id: null,
type,
name: null,
topic: null,
icon_hash: null,
url: null,
parent_id: null,
position: null,
owner_id: null,
recipient_ids: recipientIds,
nsfw: null,
content_warning_level: null,
content_warning_text: null,
rate_limit_per_user: null,
bitrate: null,
user_limit: null,
voice_connection_limit: null,
rtc_region: null,
last_message_id: null,
last_pin_timestamp: null,
permission_overwrites: null,
nicks: null,
soft_deleted: false,
indexed_at: null,
version: 1,
} satisfies ChannelRow);
}
function createRelationshipRequestService(
relationships: Array<Relationship>,
inverseRelationships: Map<UserID, Relationship>,
userCacheService: UserCacheService,
): UserRelationshipRequestService {
return new UserRelationshipRequestService(
{
getRelationships: async () => relationships,
getRelationship: async (params: {userId: UserID}) => inverseRelationships.get(params.userId) ?? null,
} as unknown as UserRelationshipService,
{} as UserChannelService,
userCacheService,
);
}
describe('list endpoint user partial prefetch', () => {
test('resolves every relationship from a single batched user partial fetch', async () => {
const viewerId = createUserID(9000n);
const targetIds = [createUserID(9001n), createUserID(9002n), createUserID(9003n), createUserID(9004n)];
const partials = createPartials(targetIds);
const usersServiceClient = new RecordingUsersServiceClient(partials);
const relationships = targetIds.map((targetId) => createRelationship(viewerId, targetId, true));
const inverseRelationships = new Map(
targetIds.map((targetId) => [targetId, createRelationship(targetId, viewerId, false)]),
);
const service = createRelationshipRequestService(
relationships,
inverseRelationships,
new UserCacheService(usersServiceClient),
);
const response = await service.listRelationships({userId: viewerId, requestCache: createRequestCache()});
expect(usersServiceClient.requests).toEqual([targetIds]);
expect(response).toEqual(
targetIds.map((targetId) => ({
id: targetId.toString(),
type: RelationshipTypes.FRIEND,
user: partials.get(targetId),
nickname: null,
share_voice_activity: true,
friend_shares_voice_activity: false,
})),
);
});
test('keeps the deleted-user fallback for relationship targets the users service drops', async () => {
const viewerId = createUserID(9100n);
const knownId = createUserID(9101n);
const missingId = createUserID(9102n);
const usersServiceClient = new RecordingUsersServiceClient(createPartials([knownId]));
const relationships = [createRelationship(viewerId, knownId, true), createRelationship(viewerId, missingId, true)];
const service = createRelationshipRequestService(
relationships,
new Map(),
new UserCacheService(usersServiceClient),
);
const response = await service.listRelationships({userId: viewerId, requestCache: createRequestCache()});
expect(usersServiceClient.requests).toEqual([[knownId, missingId]]);
expect(response[1]?.user).toMatchObject({
id: missingId.toString(),
username: DELETED_USER_USERNAME,
global_name: DELETED_USER_GLOBAL_NAME,
});
expect(response[1]?.friend_shares_voice_activity).toBe(true);
});
test('resolves every private channel recipient from a single batched user partial fetch', async () => {
const viewerId = createUserID(9200n);
const friendId = createUserID(9201n);
const groupMemberId = createUserID(9202n);
const partials = createPartials([friendId, groupMemberId]);
const usersServiceClient = new RecordingUsersServiceClient(partials);
const channels = [
createPrivateChannel(createChannelID(9300n), ChannelTypes.DM, new Set([viewerId, friendId])),
createPrivateChannel(createChannelID(9301n), ChannelTypes.GROUP_DM, new Set([viewerId, friendId, groupMemberId])),
createPrivateChannel(createChannelID(9302n), ChannelTypes.DM_PERSONAL_NOTES, new Set([viewerId])),
];
const service = new UserChannelRequestService(
{getPrivateChannels: async () => channels} as unknown as UserChannelService,
new UserCacheService(usersServiceClient),
);
const response = await service.listPrivateChannels({userId: viewerId, requestCache: createRequestCache()});
expect(usersServiceClient.requests).toEqual([[friendId, groupMemberId]]);
expect(response[0]?.recipients).toEqual([partials.get(friendId)]);
expect(response[1]?.recipients).toEqual([partials.get(friendId), partials.get(groupMemberId)]);
expect(response[2]?.recipients).toBeUndefined();
});
});