fix: avoid stale tbank grpc deadlines
All checks were successful
All checks were successful
This commit is contained in:
parent
8c2a6c9e3c
commit
a8715258ca
@ -67,4 +67,74 @@ describe('BrokerPortfolioService', () => {
|
|||||||
'tbankPortfolioTtl',
|
'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),
|
(portfolio.positions ?? []).map((position) => position.instrumentUid).filter(Boolean),
|
||||||
),
|
),
|
||||||
) as string[];
|
) as string[];
|
||||||
const entries = await Promise.all(
|
const results = await Promise.allSettled(
|
||||||
ids.map(async (id) => [id, await this.instrumentsService.findByInstrumentUid(id)] as const),
|
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(
|
return new Map(
|
||||||
entries.filter((entry): entry is readonly [string, TBankInstrument] => entry[1] !== null),
|
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>,
|
method: GrpcUnary<TRequest, TResponse>,
|
||||||
request: TRequest,
|
request: TRequest,
|
||||||
): Promise<TResponse> {
|
): Promise<TResponse> {
|
||||||
const metadata = this.createMetadata();
|
|
||||||
const deadline = new Date(Date.now() + this.requestTimeoutMs);
|
|
||||||
|
|
||||||
return this.queue.add(
|
return this.queue.add(
|
||||||
() =>
|
() =>
|
||||||
new Promise<TResponse>((resolve, reject) => {
|
new Promise<TResponse>((resolve, reject) => {
|
||||||
|
const metadata = this.createMetadata();
|
||||||
|
const deadline = new Date(Date.now() + this.requestTimeoutMs);
|
||||||
|
|
||||||
method(request, metadata, { deadline }, (error, response) => {
|
method(request, metadata, { deadline }, (error, response) => {
|
||||||
if (error) {
|
if (error) {
|
||||||
reject(this.mapGrpcError(label, 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