perf: parallelize S3 object part fetch + cache Telegram file info
Optimize the S3 GET path for chunked/multipart objects and reduce Telegram API round-trips: - object-stream: fetch object parts concurrently (bounded, in-order fan-in) instead of serializing N sequential Telegram CDN fetches. Response latency is now ~the slowest part fetch, not the sum of all part fetches. - bot-pool.getFileInfo: cache file_id -> file_path in the existing in-memory cache so repeated S3 GET/HEAD of the same object skip the Telegram API call (file-controller had its own cache wrapper; the S3 path did not). - chunked-storage + s3-controller: resolve multipart/chunked part CDN URLs concurrently via Promise.all instead of sequentially. - s3-controller multipart: await writer.end() before re-reading the temp part file to avoid a flush race. Adds object-stream-parallel.test.ts covering in-order fan-in, byte ranges, and single-part responses even when the slowest part resolves out of order.
This commit is contained in:
@@ -7,6 +7,7 @@ import type {
|
||||
} from '../../domain/ports/telegram-service';
|
||||
import { config } from '../../env';
|
||||
import logger from '../../shared/logger/index';
|
||||
import { fileInfoCache } from '../cache/index';
|
||||
import {
|
||||
buildSendPayload,
|
||||
extractUploadedFile,
|
||||
@@ -259,18 +260,31 @@ export class BotPool implements ITelegramService {
|
||||
}
|
||||
|
||||
async getFileInfo(telegramFileId: string): Promise<TelegramFileInfo> {
|
||||
// Telegram file_id → file_path mapping is stable for the lifetime of the
|
||||
// file. Cache it to avoid a Telegram API round-trip on every S3 GET / HEAD
|
||||
// of the same object. TTL 1h; a cached (possibly stale) file_path would
|
||||
// only surface if Telegram recycles a file_id, which it does not for
|
||||
// documents we own.
|
||||
const cacheKey = `file_info_${telegramFileId}`;
|
||||
const cached = fileInfoCache.get(cacheKey) as TelegramFileInfo | null;
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
let lastError: unknown;
|
||||
for (const bot of this.bots) {
|
||||
for (let retry = 0; retry <= MAX_TRANSIENT_RETRIES; retry++) {
|
||||
try {
|
||||
const result = await bot.instance.telegram.getFile(telegramFileId);
|
||||
const fileData = result as unknown as Omit<TelegramFileInfo, 'bot_token'>;
|
||||
return {
|
||||
const fileInfo: TelegramFileInfo = {
|
||||
file_size: fileData.file_size || 0,
|
||||
mime_type: fileData.mime_type || 'application/octet-stream',
|
||||
file_path: fileData.file_path || '',
|
||||
bot_token: bot.token,
|
||||
};
|
||||
fileInfoCache.set(cacheKey, fileInfo);
|
||||
return fileInfo;
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
const errorStr = error instanceof Error ? error.message : String(error);
|
||||
|
||||
@@ -229,21 +229,27 @@ export class ChunkedStorage {
|
||||
*/
|
||||
async buildChunkedObjectSources(file: FileEntity): Promise<ObjectPartSource[]> {
|
||||
const parts = await this.filePartRepository.listByFileId(file.id);
|
||||
const sources: ObjectPartSource[] = [];
|
||||
|
||||
for (const part of parts) {
|
||||
const fileInfo = await this.telegramService.getFileInfo(part.telegramFileId);
|
||||
sources.push({
|
||||
telegramFileId: part.telegramFileId,
|
||||
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
|
||||
sizeBytes: part.sizeBytes,
|
||||
storedSizeBytes: part.storedSizeBytes,
|
||||
compressionAlgorithm: part.compressionAlgorithm,
|
||||
partNumber: part.partNumber,
|
||||
});
|
||||
}
|
||||
// Resolve every part's Telegram CDN URL concurrently (each is an independent
|
||||
// getFile call) so total resolution time is ~1 round-trip, not N.
|
||||
const partInfos = await Promise.all(
|
||||
parts.map(async (part) => {
|
||||
const fileInfo = await this.telegramService.getFileInfo(part.telegramFileId);
|
||||
return {
|
||||
part,
|
||||
url: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
|
||||
};
|
||||
}),
|
||||
);
|
||||
|
||||
return sources;
|
||||
return partInfos.map(({ part, url }) => ({
|
||||
telegramFileId: part.telegramFileId,
|
||||
telegramUrl: url,
|
||||
sizeBytes: part.sizeBytes,
|
||||
storedSizeBytes: part.storedSizeBytes,
|
||||
compressionAlgorithm: part.compressionAlgorithm,
|
||||
partNumber: part.partNumber,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user