Исправить мгновенное истечение gRPC deadline при запросе портфеля T-Bank #16

Merged
ksv741 merged 1 commits from codex/tbank-deadline-queue-fix into main 2026-06-17 12:10:52 +03:00
5 changed files with 162 additions and 4 deletions

View File

@ -67,4 +67,74 @@ describe('BrokerPortfolioService', () => {
'tbankPortfolioTtl',
);
});
it('returns portfolio when one instrument enrichment request fails', async () => {
vi.mocked(accounts.findById).mockResolvedValue({
id: 'acc-1',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: null,
accessLevel: null,
});
vi.mocked(cache.getOrFetch).mockImplementation(
async (_prefix: string, _parts: string[], fetchFn: () => Promise<unknown>) => ({
data: await fetchFn(),
fromCache: false,
cachedAt: null,
}),
);
vi.mocked(client.getServiceClient).mockReturnValue({
getPortfolio: vi.fn(),
getPositions: vi.fn(),
} as any);
vi.mocked(client.callUnary)
.mockResolvedValueOnce({
accountId: 'acc-1',
totalAmountPortfolio: { currency: 'rub', units: '1000', nano: 0 },
positions: [
{
figi: 'figi-1',
instrumentUid: 'uid-1',
quantity: { units: '1', nano: 0 },
},
{
figi: 'figi-2',
instrumentUid: 'uid-2',
quantity: { units: '2', nano: 0 },
},
],
})
.mockResolvedValueOnce({
accountId: 'acc-1',
money: [],
blocked: [],
securities: [],
});
vi.mocked(instruments.findByInstrumentUid)
.mockResolvedValueOnce({
uid: 'uid-1',
figi: 'figi-1',
ticker: 'AAA',
classCode: 'TQBR',
name: 'First share',
instrumentType: 'share',
})
.mockRejectedValueOnce(new Error('instrument lookup failed'));
const service = new BrokerPortfolioService(accounts, instruments, client, cache);
const result = await service.getPortfolio('acc-1');
expect(result.data.positions).toHaveLength(2);
expect(result.data.positions[0]).toMatchObject({
instrumentUid: 'uid-1',
ticker: 'AAA',
name: 'First share',
});
expect(result.data.positions[1]).toMatchObject({
instrumentUid: 'uid-2',
ticker: null,
name: null,
});
});
});

View File

@ -70,9 +70,12 @@ export class BrokerPortfolioService {
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
),
) as string[];
const entries = await Promise.all(
const results = await Promise.allSettled(
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
);
const entries = results.flatMap((result) =>
result.status === 'fulfilled' ? [result.value] : [],
);
return new Map(
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),

View File

@ -112,4 +112,47 @@ describe('TBankClientService', () => {
}),
});
});
it('starts grpc deadline when queued call actually executes', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-06-17T07:00:00.000Z'));
try {
const queuedConfig = {
get: vi.fn((key: string, fallback?: unknown) => {
const values: Record<string, unknown> = {
'app.tbank.token': 'token-1',
'app.tbank.rateLimitPerSecond': 1,
'app.tbank.requestTimeoutMs': 1000,
};
return values[key] ?? fallback;
}),
} as unknown as ConfigService;
const service = new TBankClientService(queuedConfig);
const deadlines: number[] = [];
const startedAt: number[] = [];
const method = vi.fn((_request, _metadata, options, callback) => {
deadlines.push((options.deadline as Date).getTime());
startedAt.push(Date.now());
callback(null, {});
return unaryCall;
});
const first = service.callUnary('UsersService/GetAccounts', method, {});
const second = service.callUnary('UsersService/GetAccounts', method, {});
await first;
expect(startedAt).toEqual([Date.parse('2026-06-17T07:00:00.000Z')]);
vi.setSystemTime(new Date('2026-06-17T07:00:01.000Z'));
await vi.advanceTimersByTimeAsync(1000);
await second;
expect(startedAt[1]).toBeGreaterThan(startedAt[0]);
expect(deadlines).toEqual(startedAt.map((started) => started + 1000));
} finally {
vi.useRealTimers();
}
});
});

View File

@ -121,12 +121,12 @@ export class TBankClientService {
method: GrpcUnary<TRequest, TResponse>,
request: TRequest,
): Promise<TResponse> {
const metadata = this.createMetadata();
const deadline = new Date(Date.now() + this.requestTimeoutMs);
return this.queue.add(
() =>
new Promise<TResponse>((resolve, reject) => {
const metadata = this.createMetadata();
const deadline = new Date(Date.now() + this.requestTimeoutMs);
method(request, metadata, { deadline }, (error, response) => {
if (error) {
reject(this.mapGrpcError(label, error));

View File

@ -0,0 +1,42 @@
# Исправление gRPC deadline для T-Bank портфеля
## Контекст
При запросе брокерского портфеля T-Bank бэкенд дополнительно обогащает позиции через
`InstrumentsService/GetInstrumentBy`. Для портфеля с несколькими позициями эти запросы запускаются пачкой,
но `TBankClientService` пропускает реальные gRPC-вызовы через локальный `PQueue` rate limiter.
Сейчас `deadline` создаётся до постановки задачи в очередь. Если вызов ждёт rate limiter, время ожидания
съедает `T_BANK_REQUEST_TIMEOUT_MS`, и gRPC может завершиться мгновенным
`DEADLINE_EXCEEDED after 0.000/0.001s` ещё до сетевого запроса.
## Цель
Сделать так, чтобы локальное ожидание в `PQueue` не расходовало gRPC deadline, а сбой обогащения отдельного
инструмента не ломал весь ответ портфеля.
## Границы
- Не менять публичный API `/api/v1/broker/accounts/:accountId/portfolio`.
- Не менять значения переменных окружения и конфигурацию rate limiter.
- Не добавлять retry/backoff в этом исправлении.
- Не менять стратегию кеширования инструментов.
## Acceptance criteria
- `TBankClientService.callUnary()` создаёт `Metadata` и `deadline` непосредственно перед фактическим
gRPC-вызовом внутри задачи `PQueue`.
- `T_BANK_REQUEST_TIMEOUT_MS` измеряет время выполнения upstream gRPC-вызова, а не время ожидания локальной
очереди.
- Есть regression test, который доказывает, что второй queued-вызов получает deadline после ожидания очереди.
- `BrokerPortfolioService` строит карту инструментов best-effort: ошибка одного `GetInstrumentBy` не роняет
весь портфель.
- Есть regression test, который доказывает, что портфель возвращается, если один инструмент не удалось
обогатить.
## Проверка
- Targeted tests:
`npm run test -w apps/backend -- src/modules/tbank/services/tbank-client.service.spec.ts src/modules/tbank/services/broker-portfolio.service.spec.ts`
- Full backend tests:
`npm run test:backend`