Skip to content

Commit b9028f8

Browse files
fix(api): cap unbounded async concurrency with p-limit
Replace unbounded Promise.all fan-out with p-limit in 6 hotspots to prevent burst load on DB/filesystem/socket backends: - extension-json.loader: filesystem traversal (cap=8) - translation.controller: DB upserts during refresh (cap=10) - Handler: attachment storage (cap=4) - attachment-ability.guard: permission checks (cap=10) - memory-store: memory entry persistence (cap=8) - app.service: socket room joins (cap=15) Closes #4 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8258744 commit b9028f8

6 files changed

Lines changed: 75 additions & 48 deletions

File tree

packages/api/src/app.service.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import type { IntegrationHealthResponse } from '@hexabot-ai/types';
88
import { ForbiddenException, Injectable } from '@nestjs/common';
99
import { OnEvent } from '@nestjs/event-emitter';
10+
import pLimit from 'p-limit';
1011

1112
import { BaseOrmEntity } from './database';
1213
import { HealthService } from './health/health.service';
@@ -80,8 +81,10 @@ export class AppService {
8081
),
8182
),
8283
);
83-
84-
await Promise.all(subscribe.map((room) => req.socket.join(room)));
84+
const socketLimit = pLimit(15);
85+
await Promise.all(
86+
subscribe.map((room) => socketLimit(() => req.socket.join(room))),
87+
);
8588

8689
return res.status(200).json({
8790
success: true,

packages/api/src/attachment/guards/attachment-ability.guard.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
} from '@nestjs/common';
1717
import { isUUID } from 'class-validator';
1818
import { Request } from 'express';
19+
import pLimit from 'p-limit';
1920
import qs from 'qs';
2021
import { FindOneOptions, In } from 'typeorm';
2122

@@ -179,10 +180,12 @@ export class AttachmentGuard implements CanActivate {
179180
return false;
180181
}
181182

183+
const dbLimit = pLimit(10);
184+
182185
return (
183186
await Promise.all(
184187
permissions.map(([identity, action]) =>
185-
this.hasPermission(user, identity, action),
188+
dbLimit(() => this.hasPermission(user, identity, action)),
186189
),
187190
)
188191
).every(Boolean);
@@ -244,9 +247,13 @@ export class AttachmentGuard implements CanActivate {
244247
throw new BadRequestException('Invalid resource ref');
245248
}
246249

250+
const dbLimit = pLimit(10);
251+
247252
return (
248253
await Promise.all(
249-
resourceRef.map((c) => this.isAuthorized(Action.READ, user, c)),
254+
resourceRef.map((c) =>
255+
dbLimit(() => this.isAuthorized(Action.READ, user, c)),
256+
),
250257
)
251258
).every(Boolean);
252259
} else {

packages/api/src/channel/lib/Handler.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { Inject, Injectable, OnModuleInit, Type } from '@nestjs/common';
1818
import { ModuleRef } from '@nestjs/core';
1919
import { Request, Response } from 'express';
2020
import mime from 'mime';
21+
import pLimit from 'p-limit';
2122
import z from 'zod';
2223

2324
import { AttachmentService } from '@/attachment/services/attachment.service';
@@ -230,18 +231,21 @@ export default abstract class ChannelHandler<
230231

231232
const metadatas = await this.getMessageAttachments(event);
232233
const subscriber = event.getInitiator();
234+
const storageLimit = pLimit(4);
233235
const attachments = await Promise.all(
234-
metadatas.map(({ file, name, type, size }) => {
235-
return this.attachmentService.store(file, {
236-
name: `${name ? `${name}-` : ''}${randomUUID()}.${mime.extension(type)}`,
237-
type,
238-
size,
239-
resourceRef: AttachmentResourceRef.MessageAttachment,
240-
access: AttachmentAccess.Private,
241-
createdByRef: AttachmentCreatedByRef.Subscriber,
242-
createdBy: subscriber.id,
243-
});
244-
}),
236+
metadatas.map(({ file, name, type, size }) =>
237+
storageLimit(() =>
238+
this.attachmentService.store(file, {
239+
name: `${name ? `${name}-` : ''}${randomUUID()}.${mime.extension(type)}`,
240+
type,
241+
size,
242+
resourceRef: AttachmentResourceRef.MessageAttachment,
243+
access: AttachmentAccess.Private,
244+
createdByRef: AttachmentCreatedByRef.Subscriber,
245+
createdBy: subscriber.id,
246+
}),
247+
),
248+
),
245249
);
246250

247251
event.setPersistedAttachments(attachments);

packages/api/src/i18n/controllers/translation.controller.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
Post,
1616
Query,
1717
} from '@nestjs/common';
18+
import pLimit from 'p-limit';
1819
import { FindManyOptions, In, Not } from 'typeorm';
1920
import { DeleteResult } from 'typeorm/driver/mongodb/typings';
2021

@@ -110,10 +111,13 @@ export class TranslationController extends BaseOrmController<TranslationOrmEntit
110111
return str && strings.indexOf(str) == pos;
111112
});
112113
// Perform refresh
114+
const dbLimit = pLimit(10);
113115
const queue = strings.map((str) =>
114-
this.translationService.findOneOrCreate(
115-
{ where: { str } },
116-
{ str, translations: defaultTrans },
116+
dbLimit(() =>
117+
this.translationService.findOneOrCreate(
118+
{ where: { str } },
119+
{ str, translations: defaultTrans },
120+
),
117121
),
118122
);
119123
await Promise.all(queue);

packages/api/src/i18n/loaders/extension-json.loader.ts

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
I18nLoader,
1717
I18nTranslation,
1818
} from 'nestjs-i18n';
19+
import pLimit from 'p-limit';
1920
import { Observable, Subject, merge, of, switchMap } from 'rxjs';
2021

2122
import { deepMerge, isPlainObject } from '@/utils/helpers/object';
@@ -158,17 +159,18 @@ export class ExtensionJsonLoader extends I18nLoader implements OnModuleDestroy {
158159
private async resolveExtensionTranslationFiles(
159160
extensionRootPaths: string[],
160161
): Promise<string[]> {
162+
const fsLimit = pLimit(8);
161163
const i18nDirectories = (
162164
await Promise.all(
163165
extensionRootPaths.map((rootPath) =>
164-
this.findI18nDirectories(rootPath),
166+
fsLimit(() => this.findI18nDirectories(rootPath)),
165167
),
166168
)
167169
).flat();
168170
const translationFiles = (
169171
await Promise.all(
170172
i18nDirectories.map((i18nDir) =>
171-
this.findTranslationFilesInI18nDirectory(i18nDir),
173+
fsLimit(() => this.findTranslationFilesInI18nDirectory(i18nDir)),
172174
),
173175
)
174176
).flat();
@@ -185,17 +187,20 @@ export class ExtensionJsonLoader extends I18nLoader implements OnModuleDestroy {
185187
if (!entries) {
186188
return [];
187189
}
190+
const fsLimit = pLimit(8);
188191
const nestedResults = await Promise.all(
189192
entries
190193
.filter((entry) => entry.isDirectory())
191-
.map(async (entry) => {
192-
const entryPath = path.join(rootPath, entry.name);
193-
if (entry.name === 'i18n') {
194-
return [entryPath];
195-
}
196-
197-
return this.findI18nDirectories(entryPath);
198-
}),
194+
.map((entry) =>
195+
fsLimit(async () => {
196+
const entryPath = path.join(rootPath, entry.name);
197+
if (entry.name === 'i18n') {
198+
return [entryPath];
199+
}
200+
201+
return this.findI18nDirectories(entryPath);
202+
}),
203+
),
199204
);
200205

201206
return nestedResults.flat();

packages/api/src/workflow/utils/memory-store.ts

Lines changed: 24 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
*/
66

77
import { MemoryDefinition, MemoryRecordFull } from '@hexabot-ai/types';
8+
import pLimit from 'p-limit';
89
import { ZodSchema, z } from 'zod';
910

1011
import { cloneObject } from '@/utils/helpers/clone';
@@ -376,27 +377,30 @@ export class MemoryStore {
376377
return {};
377378
}
378379

380+
const dbLimit = pLimit(8);
379381
const updates = await Promise.all(
380-
entries.map(async ([slug, value]) => {
381-
const currentValue = this.raw[slug];
382-
const canMerge =
383-
currentValue !== null &&
384-
value !== null &&
385-
typeof currentValue === 'object' &&
386-
typeof value === 'object' &&
387-
!Array.isArray(currentValue) &&
388-
!Array.isArray(value);
389-
const nextValue = canMerge
390-
? deepMerge(cloneObject(currentValue), value)
391-
: value;
392-
const parsedValue = await this.updateStoreEntry(
393-
slug,
394-
nextValue,
395-
persistRecord,
396-
);
397-
398-
return [slug, parsedValue] as const;
399-
}),
382+
entries.map(([slug, value]) =>
383+
dbLimit(async () => {
384+
const currentValue = this.raw[slug];
385+
const canMerge =
386+
currentValue !== null &&
387+
value !== null &&
388+
typeof currentValue === 'object' &&
389+
typeof value === 'object' &&
390+
!Array.isArray(currentValue) &&
391+
!Array.isArray(value);
392+
const nextValue = canMerge
393+
? deepMerge(cloneObject(currentValue), value)
394+
: value;
395+
const parsedValue = await this.updateStoreEntry(
396+
slug,
397+
nextValue,
398+
persistRecord,
399+
);
400+
401+
return [slug, parsedValue] as const;
402+
}),
403+
),
400404
);
401405

402406
return Object.fromEntries(updates);

0 commit comments

Comments
 (0)