perf(api): construct request services lazily (#2166)

This commit is contained in:
Hampus
2026-08-30 22:45:43 +02:00
committed by GitHub
parent 3ca73901c9
commit 02e614632f
5 changed files with 1142 additions and 488 deletions
@@ -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);
});
});