From a8715258ca5d8fa919ae7fef93cf19c214cefcfd Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Wed, 17 Jun 2026 12:08:48 +0300 Subject: [PATCH] fix: avoid stale tbank grpc deadlines --- .../services/broker-portfolio.service.spec.ts | 70 +++++++++++++++++++ .../services/broker-portfolio.service.ts | 5 +- .../services/tbank-client.service.spec.ts | 43 ++++++++++++ .../tbank/services/tbank-client.service.ts | 6 +- .../2026-06-17-tbank-deadline-queue-fix.md | 42 +++++++++++ 5 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/specs/2026-06-17-tbank-deadline-queue-fix.md diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts index dc3b2d5..682d12f 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.spec.ts @@ -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) => ({ + 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, + }); + }); }); diff --git a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts index a4ded6f..7ec14a8 100644 --- a/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts +++ b/apps/backend/src/modules/tbank/services/broker-portfolio.service.ts @@ -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), diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts index 4aa8cb1..09e30ef 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.spec.ts @@ -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 = { + '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(); + } + }); }); diff --git a/apps/backend/src/modules/tbank/services/tbank-client.service.ts b/apps/backend/src/modules/tbank/services/tbank-client.service.ts index 52be652..780b327 100644 --- a/apps/backend/src/modules/tbank/services/tbank-client.service.ts +++ b/apps/backend/src/modules/tbank/services/tbank-client.service.ts @@ -121,12 +121,12 @@ export class TBankClientService { method: GrpcUnary, request: TRequest, ): Promise { - const metadata = this.createMetadata(); - const deadline = new Date(Date.now() + this.requestTimeoutMs); - return this.queue.add( () => new Promise((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)); diff --git a/docs/superpowers/specs/2026-06-17-tbank-deadline-queue-fix.md b/docs/superpowers/specs/2026-06-17-tbank-deadline-queue-fix.md new file mode 100644 index 0000000..c5d5a7a --- /dev/null +++ b/docs/superpowers/specs/2026-06-17-tbank-deadline-queue-fix.md @@ -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` -- 2.47.2