mirror of
https://github.com/fluxerapp/fluxer.git
synced 2026-09-03 05:10:25 +03:00
perf(api): construct request services lazily (#2166)
This commit is contained in:
@@ -58,75 +58,95 @@ interface GuildStackServiceFactoryDependencies {
|
||||
ipInfoService: IpInfoService;
|
||||
}
|
||||
|
||||
interface GuildStackServices {
|
||||
export interface GuildStackServices {
|
||||
packService: PackService;
|
||||
channelService: ChannelService;
|
||||
guildService: GuildService;
|
||||
inviteService: InviteService;
|
||||
}
|
||||
|
||||
class LazyGuildStackServices implements GuildStackServices {
|
||||
private cachedPackService: PackService | undefined;
|
||||
private cachedChannelService: ChannelService | undefined;
|
||||
private cachedGuildService: GuildService | undefined;
|
||||
private cachedInviteService: InviteService | undefined;
|
||||
|
||||
constructor(private readonly dependencies: GuildStackServiceFactoryDependencies) {}
|
||||
|
||||
get packService(): PackService {
|
||||
this.cachedPackService ??= new PackService(
|
||||
this.dependencies.apiContext,
|
||||
this.dependencies.packRepository,
|
||||
this.dependencies.guildRepository,
|
||||
this.dependencies.avatarService,
|
||||
this.dependencies.expressionAssetPurger,
|
||||
this.dependencies.userCacheService,
|
||||
this.dependencies.limitConfigService,
|
||||
);
|
||||
return this.cachedPackService;
|
||||
}
|
||||
|
||||
get channelService(): ChannelService {
|
||||
this.cachedChannelService ??= new ChannelService(
|
||||
this.dependencies.apiContext,
|
||||
this.dependencies.channelRepository,
|
||||
this.dependencies.userRepository,
|
||||
this.dependencies.guildRepository,
|
||||
this.packService,
|
||||
this.dependencies.userCacheService,
|
||||
this.dependencies.embedService,
|
||||
this.dependencies.readStateService,
|
||||
this.dependencies.storageService,
|
||||
this.dependencies.attachmentUploadTraceRepository,
|
||||
this.dependencies.avatarService,
|
||||
this.dependencies.virusScanService,
|
||||
this.dependencies.purgeQueue,
|
||||
this.dependencies.favoriteMemeRepository,
|
||||
this.dependencies.guildAuditLogService,
|
||||
this.dependencies.voiceRoomStore,
|
||||
this.dependencies.liveKitService,
|
||||
this.dependencies.inviteRepository,
|
||||
this.dependencies.webhookRepository,
|
||||
this.dependencies.limitConfigService,
|
||||
this.dependencies.voiceAvailabilityService,
|
||||
);
|
||||
return this.cachedChannelService;
|
||||
}
|
||||
|
||||
get guildService(): GuildService {
|
||||
this.cachedGuildService ??= new GuildService(
|
||||
this.dependencies.apiContext,
|
||||
this.dependencies.guildRepository,
|
||||
this.dependencies.channelRepository,
|
||||
this.dependencies.inviteRepository,
|
||||
this.channelService,
|
||||
this.dependencies.userCacheService,
|
||||
this.dependencies.entityAssetService,
|
||||
this.dependencies.avatarService,
|
||||
this.dependencies.assetDeletionQueue,
|
||||
this.dependencies.webhookRepository,
|
||||
this.dependencies.guildAuditLogService,
|
||||
this.dependencies.limitConfigService,
|
||||
this.dependencies.ipInfoService,
|
||||
);
|
||||
return this.cachedGuildService;
|
||||
}
|
||||
|
||||
get inviteService(): InviteService {
|
||||
this.cachedInviteService ??= new InviteService(
|
||||
this.dependencies.apiContext,
|
||||
this.dependencies.inviteRepository,
|
||||
this.guildService,
|
||||
this.channelService,
|
||||
this.dependencies.guildAuditLogService,
|
||||
this.dependencies.packRepository,
|
||||
this.packService,
|
||||
this.dependencies.limitConfigService,
|
||||
);
|
||||
return this.cachedInviteService;
|
||||
}
|
||||
}
|
||||
|
||||
export function createGuildStackServices(dependencies: GuildStackServiceFactoryDependencies): GuildStackServices {
|
||||
const packService = new PackService(
|
||||
dependencies.apiContext,
|
||||
dependencies.packRepository,
|
||||
dependencies.guildRepository,
|
||||
dependencies.avatarService,
|
||||
dependencies.expressionAssetPurger,
|
||||
dependencies.userCacheService,
|
||||
dependencies.limitConfigService,
|
||||
);
|
||||
const channelService = new ChannelService(
|
||||
dependencies.apiContext,
|
||||
dependencies.channelRepository,
|
||||
dependencies.userRepository,
|
||||
dependencies.guildRepository,
|
||||
packService,
|
||||
dependencies.userCacheService,
|
||||
dependencies.embedService,
|
||||
dependencies.readStateService,
|
||||
dependencies.storageService,
|
||||
dependencies.attachmentUploadTraceRepository,
|
||||
dependencies.avatarService,
|
||||
dependencies.virusScanService,
|
||||
dependencies.purgeQueue,
|
||||
dependencies.favoriteMemeRepository,
|
||||
dependencies.guildAuditLogService,
|
||||
dependencies.voiceRoomStore,
|
||||
dependencies.liveKitService,
|
||||
dependencies.inviteRepository,
|
||||
dependencies.webhookRepository,
|
||||
dependencies.limitConfigService,
|
||||
dependencies.voiceAvailabilityService,
|
||||
);
|
||||
const guildService = new GuildService(
|
||||
dependencies.apiContext,
|
||||
dependencies.guildRepository,
|
||||
dependencies.channelRepository,
|
||||
dependencies.inviteRepository,
|
||||
channelService,
|
||||
dependencies.userCacheService,
|
||||
dependencies.entityAssetService,
|
||||
dependencies.avatarService,
|
||||
dependencies.assetDeletionQueue,
|
||||
dependencies.webhookRepository,
|
||||
dependencies.guildAuditLogService,
|
||||
dependencies.limitConfigService,
|
||||
dependencies.ipInfoService,
|
||||
);
|
||||
const inviteService = new InviteService(
|
||||
dependencies.apiContext,
|
||||
dependencies.inviteRepository,
|
||||
guildService,
|
||||
channelService,
|
||||
dependencies.guildAuditLogService,
|
||||
dependencies.packRepository,
|
||||
packService,
|
||||
dependencies.limitConfigService,
|
||||
);
|
||||
return {
|
||||
packService,
|
||||
channelService,
|
||||
guildService,
|
||||
inviteService,
|
||||
};
|
||||
return new LazyGuildStackServices(dependencies);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import type {Context} from 'hono';
|
||||
import type {HonoEnv} from '../types/HonoEnv';
|
||||
|
||||
type RequestVariables = HonoEnv['Variables'];
|
||||
|
||||
type EagerlySetVariable = 'apiContext' | 'sudoModeValid';
|
||||
|
||||
type ExternallySetVariable =
|
||||
| 'adminApiKey'
|
||||
| 'adminApiKeyAcls'
|
||||
| 'adminUserAcls'
|
||||
| 'adminUserId'
|
||||
| 'auditLogReason'
|
||||
| 'authSession'
|
||||
| 'authToken'
|
||||
| 'authTokenType'
|
||||
| 'authUserId'
|
||||
| 'authViaCookie'
|
||||
| 'channelUpdateType'
|
||||
| 'oauthBearerAllowed'
|
||||
| 'oauthBearerApplicationId'
|
||||
| 'oauthBearerScopes'
|
||||
| 'oauthBearerToken'
|
||||
| 'oauthBearerUserId'
|
||||
| 'requestCache'
|
||||
| 'requestId'
|
||||
| 'requestLocale'
|
||||
| 'responseSchema'
|
||||
| 'sudoModeToken'
|
||||
| 'user';
|
||||
|
||||
type ConfigurationDependentService = 'ageVerificationService' | 'donationService';
|
||||
|
||||
export type RequestScopedServices = Omit<
|
||||
RequestVariables,
|
||||
EagerlySetVariable | ExternallySetVariable | ConfigurationDependentService
|
||||
> & {
|
||||
readonly [Key in ConfigurationDependentService]: RequestVariables[Key] | undefined;
|
||||
};
|
||||
|
||||
export type LazyServiceProvider = {
|
||||
readonly [Key in keyof RequestVariables]?: RequestVariables[Key];
|
||||
};
|
||||
|
||||
export function installLazyServices(ctx: Context<HonoEnv>, provider: LazyServiceProvider): void {
|
||||
const readVariable = ctx.get;
|
||||
const writeVariable = ctx.set;
|
||||
ctx.get = <Key extends keyof RequestVariables>(key: Key): RequestVariables[Key] => {
|
||||
const existing = readVariable(key);
|
||||
if (existing !== undefined) {
|
||||
return existing;
|
||||
}
|
||||
const resolved = provider[key];
|
||||
if (resolved === undefined) {
|
||||
return existing;
|
||||
}
|
||||
writeVariable(key, resolved);
|
||||
return resolved;
|
||||
};
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -37,12 +37,14 @@ import {DonationRepository} from '../donation/DonationRepository';
|
||||
import {DownloadService} from '../download/DownloadService';
|
||||
import {createEmailProvider} from '../email/EmailProviderFactory';
|
||||
import {FavoriteMemeRepository} from '../favorite_meme/FavoriteMemeRepository';
|
||||
import {GatewayRequestService} from '../gateway/GatewayRequestService';
|
||||
import {GifService} from '../gif/GifService';
|
||||
import {createNatsGifProvider} from '../gif/NatsGifProvider';
|
||||
import {GuildAuditLogService} from '../guild/GuildAuditLogService';
|
||||
import {GuildDiscoveryRepository} from '../guild/repositories/GuildDiscoveryRepository';
|
||||
import {GuildRepository} from '../guild/repositories/GuildRepository';
|
||||
import {ExpressionAssetPurger} from '../guild/services/content/ExpressionAssetPurger';
|
||||
import {GuildDiscoveryService} from '../guild/services/GuildDiscoveryService';
|
||||
import {AssetDeletionQueue} from '../infrastructure/AssetDeletionQueue';
|
||||
import {AvatarService} from '../infrastructure/AvatarService';
|
||||
import {BunnyPurgeQueue, type IPurgeQueue, NoopPurgeQueue} from '../infrastructure/BunnyPurgeQueue';
|
||||
@@ -76,9 +78,14 @@ import {ApplicationRepository} from '../oauth/repositories/ApplicationRepository
|
||||
import {OAuth2TokenRepository} from '../oauth/repositories/OAuth2TokenRepository';
|
||||
import {PackRepository} from '../pack/PackRepository';
|
||||
import {ReadStateRepository} from '../read_state/ReadStateRepository';
|
||||
import {ReadStateRequestService} from '../read_state/ReadStateRequestService';
|
||||
import {ReadStateService} from '../read_state/ReadStateService';
|
||||
import {ReportRepository} from '../report/ReportRepository';
|
||||
import {getGuildSearchService} from '../SearchFactory';
|
||||
import {ThemeService} from '../theme/ThemeService';
|
||||
import {EntranceSoundPlayService} from '../user/entrance_sound/EntranceSoundPlayService';
|
||||
import {EntranceSoundRepository} from '../user/entrance_sound/EntranceSoundRepository';
|
||||
import {EntranceSoundService} from '../user/entrance_sound/EntranceSoundService';
|
||||
import {EmailChangeRepository} from '../user/repositories/auth/EmailChangeRepository';
|
||||
import {PasswordChangeRepository} from '../user/repositories/auth/PasswordChangeRepository';
|
||||
import {ScheduledMessageRepository} from '../user/repositories/ScheduledMessageRepository';
|
||||
@@ -432,6 +439,36 @@ export const getEntityAssetService = singleton(
|
||||
export const getAdminApiKeyService = singleton(
|
||||
() => new AdminApiKeyService(getAdminApiKeyRepository(), getSnowflakeService()),
|
||||
);
|
||||
export const getAdminArchiveService = singleton(
|
||||
() =>
|
||||
new AdminArchiveService(
|
||||
getAdminArchiveRepository(),
|
||||
getUserRepository(),
|
||||
getGuildRepository(),
|
||||
getStorageService(),
|
||||
getSnowflakeService(),
|
||||
getWorkerService(),
|
||||
),
|
||||
);
|
||||
const getEntranceSoundRepository = singleton(() => new EntranceSoundRepository());
|
||||
export const getEntranceSoundService = singleton(
|
||||
() => new EntranceSoundService(getEntranceSoundRepository(), getStorageService(), getMediaService()),
|
||||
);
|
||||
export const getEntranceSoundPlayService = singleton(
|
||||
() => new EntranceSoundPlayService(getEntranceSoundService(), getGatewayService(), getChannelRepository()),
|
||||
);
|
||||
export const getGatewayRequestService = singleton(() => new GatewayRequestService(getBotAuthService()));
|
||||
export const getGuildDiscoveryService = singleton(
|
||||
() =>
|
||||
new GuildDiscoveryService(
|
||||
getGuildDiscoveryRepository(),
|
||||
getGuildRepository(),
|
||||
getGatewayService(),
|
||||
getGuildSearchService(),
|
||||
),
|
||||
);
|
||||
export const getReadStateRequestService = singleton(() => new ReadStateRequestService(getReadStateService()));
|
||||
export const getUserCacheService = singleton(() => createUserCacheService());
|
||||
|
||||
export function createUserCacheService(): UserCacheService {
|
||||
return new UserCacheService(createUsersServiceClient());
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
import {Hono} from 'hono';
|
||||
import {afterAll, beforeAll, describe, expect, it} from 'vitest';
|
||||
import type {ApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import {createApiTestHarness} from '../../test/ApiTestHarness';
|
||||
import type {HonoEnv} from '../../types/HonoEnv';
|
||||
import {installLazyServices, type LazyServiceProvider} from '../LazyServiceProvider';
|
||||
import {ServiceMiddleware} from '../ServiceMiddleware';
|
||||
|
||||
const NO_CONTENT = 204;
|
||||
|
||||
const REQUEST_SERVICE_VARIABLES: ReadonlyArray<keyof HonoEnv['Variables']> = [
|
||||
'adminApiKeyService',
|
||||
'adminArchiveService',
|
||||
'adminService',
|
||||
'applicationRepository',
|
||||
'applicationService',
|
||||
'authRequestService',
|
||||
'blueskyOAuthService',
|
||||
'botAuthService',
|
||||
'cacheService',
|
||||
'channelRepository',
|
||||
'channelRequestService',
|
||||
'channelService',
|
||||
'connectionRequestService',
|
||||
'connectionService',
|
||||
'contactChangeLogService',
|
||||
'desktopHandoffService',
|
||||
'discoveryService',
|
||||
'downloadService',
|
||||
'emailChangeService',
|
||||
'emailService',
|
||||
'embedService',
|
||||
'entityAssetService',
|
||||
'entranceSoundPlayService',
|
||||
'entranceSoundService',
|
||||
'errorI18nService',
|
||||
'favoriteMemeRequestService',
|
||||
'favoriteMemeService',
|
||||
'gatewayRequestService',
|
||||
'gatewayService',
|
||||
'gifService',
|
||||
'guildService',
|
||||
'instanceConfigRepository',
|
||||
'inviteRequestService',
|
||||
'inviteService',
|
||||
'kvActivityTracker',
|
||||
'limitConfigService',
|
||||
'mediaService',
|
||||
'messageRequestService',
|
||||
'ncmecSubmissionService',
|
||||
'oauth2ApplicationsRequestService',
|
||||
'oauth2RequestService',
|
||||
'oauth2Service',
|
||||
'oauth2TokenRepository',
|
||||
'packRepository',
|
||||
'packService',
|
||||
'passwordChangeService',
|
||||
'rateLimitService',
|
||||
'readStateRequestService',
|
||||
'readStateService',
|
||||
'reportRequestService',
|
||||
'reportService',
|
||||
'rpcService',
|
||||
'scheduledMessageService',
|
||||
'searchService',
|
||||
'singleCommunityService',
|
||||
'snowflakeService',
|
||||
'ssoService',
|
||||
'storageService',
|
||||
'streamPreviewService',
|
||||
'streamService',
|
||||
'stripeService',
|
||||
'sweegoWebhookService',
|
||||
'themeService',
|
||||
'userAccountRequestService',
|
||||
'userActivityBuffer',
|
||||
'userAuthRequestService',
|
||||
'userCacheService',
|
||||
'userChannelRequestService',
|
||||
'userContentRequestService',
|
||||
'userRelationshipRequestService',
|
||||
'userRepository',
|
||||
'userService',
|
||||
'webhookRequestService',
|
||||
'webhookService',
|
||||
'workerService',
|
||||
];
|
||||
|
||||
describe('installLazyServices', () => {
|
||||
it('defers construction until the variable is read and memoises the result', async () => {
|
||||
let builds = 0;
|
||||
const provider: LazyServiceProvider = {
|
||||
get requestLocale() {
|
||||
builds += 1;
|
||||
return 'en-US';
|
||||
},
|
||||
};
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
installLazyServices(ctx, provider);
|
||||
expect(builds).toBe(0);
|
||||
await next();
|
||||
});
|
||||
app.get('/probe', (ctx) => {
|
||||
expect(ctx.get('requestLocale')).toBe('en-US');
|
||||
expect(ctx.get('requestLocale')).toBe('en-US');
|
||||
return ctx.body(null, NO_CONTENT);
|
||||
});
|
||||
|
||||
const response = await app.request('/probe');
|
||||
|
||||
expect(response.status).toBe(NO_CONTENT);
|
||||
expect(builds).toBe(1);
|
||||
});
|
||||
|
||||
it('prefers a value written with set over the lazy provider', async () => {
|
||||
let builds = 0;
|
||||
const provider: LazyServiceProvider = {
|
||||
get requestLocale() {
|
||||
builds += 1;
|
||||
return 'en-US';
|
||||
},
|
||||
};
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
installLazyServices(ctx, provider);
|
||||
ctx.set('requestLocale', 'fr');
|
||||
await next();
|
||||
});
|
||||
app.get('/probe', (ctx) => {
|
||||
expect(ctx.get('requestLocale')).toBe('fr');
|
||||
return ctx.body(null, NO_CONTENT);
|
||||
});
|
||||
|
||||
const response = await app.request('/probe');
|
||||
|
||||
expect(response.status).toBe(NO_CONTENT);
|
||||
expect(builds).toBe(0);
|
||||
});
|
||||
|
||||
it('leaves variables the provider does not cover untouched', async () => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(async (ctx, next) => {
|
||||
installLazyServices(ctx, {});
|
||||
await next();
|
||||
});
|
||||
app.get('/probe', (ctx) => {
|
||||
expect(ctx.get('requestLocale')).toBeUndefined();
|
||||
ctx.set('requestLocale', 'de');
|
||||
expect(ctx.get('requestLocale')).toBe('de');
|
||||
return ctx.body(null, NO_CONTENT);
|
||||
});
|
||||
|
||||
const response = await app.request('/probe');
|
||||
|
||||
expect(response.status).toBe(NO_CONTENT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ServiceMiddleware lazy request services', () => {
|
||||
let harness: ApiTestHarness;
|
||||
beforeAll(async () => {
|
||||
harness = await createApiTestHarness();
|
||||
});
|
||||
afterAll(async () => {
|
||||
await harness?.shutdown();
|
||||
});
|
||||
|
||||
it('only materialises the services a request actually reads', async () => {
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(ServiceMiddleware);
|
||||
app.get('/probe', (ctx) => {
|
||||
ctx.get('userRepository');
|
||||
return ctx.json({resolved: Object.keys(ctx.var)});
|
||||
});
|
||||
|
||||
const response = await app.request('/probe');
|
||||
const body = (await response.json()) as {resolved: Array<string>};
|
||||
|
||||
expect(body.resolved).toContain('userRepository');
|
||||
expect(body.resolved).not.toContain('guildService');
|
||||
expect(body.resolved).not.toContain('channelService');
|
||||
expect(body.resolved).not.toContain('adminService');
|
||||
expect(body.resolved).not.toContain('ssoService');
|
||||
});
|
||||
|
||||
it('resolves every service variable the middleware is responsible for', async () => {
|
||||
const missing: Array<string> = [];
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(ServiceMiddleware);
|
||||
app.get('/probe', (ctx) => {
|
||||
for (const key of REQUEST_SERVICE_VARIABLES) {
|
||||
if (ctx.get(key) === undefined) {
|
||||
missing.push(key);
|
||||
}
|
||||
}
|
||||
return ctx.body(null, NO_CONTENT);
|
||||
});
|
||||
|
||||
const response = await app.request('/probe');
|
||||
|
||||
expect(response.status).toBe(NO_CONTENT);
|
||||
expect(missing).toEqual([]);
|
||||
});
|
||||
|
||||
it('shares stateless services across requests and rebuilds request-scoped ones', async () => {
|
||||
const reads: Array<Record<string, unknown>> = [];
|
||||
const app = new Hono<HonoEnv>();
|
||||
app.use(ServiceMiddleware);
|
||||
app.get('/probe', (ctx) => {
|
||||
reads.push({
|
||||
guildService: ctx.get('guildService'),
|
||||
guildServiceAgain: ctx.get('guildService'),
|
||||
channelService: ctx.get('channelService'),
|
||||
userCacheService: ctx.get('userCacheService'),
|
||||
userRepository: ctx.get('userRepository'),
|
||||
readStateRequestService: ctx.get('readStateRequestService'),
|
||||
});
|
||||
return ctx.body(null, NO_CONTENT);
|
||||
});
|
||||
|
||||
await app.request('/probe');
|
||||
await app.request('/probe');
|
||||
|
||||
const [first, second] = reads;
|
||||
expect(first.guildService).toBe(first.guildServiceAgain);
|
||||
expect(first.guildService).not.toBe(second.guildService);
|
||||
expect(first.channelService).not.toBe(second.channelService);
|
||||
expect(first.userCacheService).toBe(second.userCacheService);
|
||||
expect(first.userRepository).toBe(second.userRepository);
|
||||
expect(first.readStateRequestService).toBe(second.readStateRequestService);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user