Исправить мгновенное истечение gRPC deadline при запросе портфеля T-Bank #16
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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`
|
||||
Loading…
x
Reference in New Issue
Block a user