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:
asepharyana
2026-08-27 14:55:24 +07:00
parent 5d2f3331de
commit 43e36b7629
5 changed files with 228 additions and 27 deletions
+15 -1
View File
@@ -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);
+19 -13
View File
@@ -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,
}));
}
/**
@@ -702,15 +702,21 @@ const handleGetMultipartObject = async (
}
const sources: ObjectPartSource[] = [];
for (const part of parts) {
const fileInfo = await botPool.getFileInfo(part.telegramFileId);
sources.push({
telegramFileId: part.telegramFileId,
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
sizeBytes: part.sizeBytes,
partNumber: part.partNumber,
});
}
// Resolve all part CDN URLs concurrently (independent getFile calls) so
// assembly latency is ~1 round-trip instead of N.
sources.push(
...(await Promise.all(
parts.map(async (part) => {
const fileInfo = await botPool.getFileInfo(part.telegramFileId);
return {
telegramFileId: part.telegramFileId,
telegramUrl: `https://api.telegram.org/file/bot${fileInfo.bot_token}/${fileInfo.file_path}`,
sizeBytes: part.sizeBytes,
partNumber: part.partNumber,
};
}),
)),
);
// H1: Always proxy — never expose bot token in redirect URL
@@ -1479,9 +1485,13 @@ const handleUploadPart = async (
hasher.update(chunk);
writer.write(chunk);
}
writer.end();
await writer.end();
} catch (error) {
writer.end();
try {
writer.end();
} catch {
// ignore during error path
}
await cleanupTempFile(tempPath);
throw error;
} finally {
+43 -2
View File
@@ -97,12 +97,53 @@ const fetchPartBody = async (planned: PlannedPart): Promise<ReadableStream<Uint8
return streamFromBytes(bytes.slice(planned.relativeStart, planned.relativeEnd + 1));
};
/**
* Maximum number of Telegram CDN part fetches run concurrently while
* assembling a chunked/multipart object response.
*
* Part fetches are initiated in parallel (bounded by this constant) to avoid
* serializing N sequential network round-trips on the Telegram CDN, then the
* results are fanned-in to the response stream in part-number order so the
* object bytes remain correctly ordered.
*/
const PART_FETCH_CONCURRENCY = 6;
/**
* Runs an async mapper over the parts with bounded concurrency, returning the
* results in the same order as the input. Each worker claims the next not-yet-
* claimed index, so array slots are filled by exactly one worker each.
*/
const mapBounded = async <T, R>(
items: T[],
limit: number,
fn: (item: T, index: number) => Promise<R>,
): Promise<R[]> => {
const results: R[] = new Array(items.length);
let next = 0;
const worker = async (): Promise<void> => {
while (true) {
const idx = next++;
if (idx >= items.length) return;
results[idx] = await fn(items[idx], idx);
}
};
const workers = Array.from({ length: Math.max(1, Math.min(limit, items.length)) }, worker);
await Promise.all(workers);
return results;
};
const concatPartStreams = (plannedParts: PlannedPart[]): ReadableStream<Uint8Array> =>
new ReadableStream<Uint8Array>({
async start(controller) {
try {
for (const planned of plannedParts) {
const stream = await fetchPartBody(planned);
// Fetch every part body concurrently (bounded) so the slowest Telegram
// CDN fetch dictates latency instead of the sum of all fetches.
const partStreams = await mapBounded(plannedParts, PART_FETCH_CONCURRENCY, fetchPartBody);
// Fan-in in part order — object bytes stay correctly ordered.
for (const stream of partStreams) {
const reader = stream.getReader();
while (true) {
const { value, done } = await reader.read();