refactor!: simplify runtime coordination #7
Keep financial ownership, atomic publication and refresh CAS. Failed refresh leaves cookies untouched; logout still clears them. Remove full-history candle prefetch and special metadata retries. Use native Pino request context instead of parallel middleware. BREAKING CHANGE: remove investment stateVersion, queued sync state, and tbank-sync status progress/resources. Consumers must observe resources through sync=observe with the updated frontend contract.
This commit is contained in:
@@ -176,46 +176,30 @@ describe('TbankClient metadata retries', () => {
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
])(
|
||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
||||
'recovers metadata after one retry for transient gRPC status %s',
|
||||
async (code) => {
|
||||
requestRpc
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
await jest.advanceTimersByTimeAsync(999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(3999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
||||
it('rejects metadata immediately when the queue is full', async () => {
|
||||
queue.size = 50;
|
||||
requestRpc.mockResolvedValue({ instrument });
|
||||
|
||||
const result = client.getInstrumentByFigi('token', 'figi');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
queue.size = 49;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'tbank.rpc.retry',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not bypass queue admission after an upstream failure', async () => {
|
||||
@@ -223,42 +207,22 @@ describe('TbankClient metadata retries', () => {
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
|
||||
queue.size = 50;
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
queue.size = 0;
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
||||
queue.size = 50;
|
||||
await Promise.all([
|
||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(6000),
|
||||
]);
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
||||
expect(logger.event).toHaveBeenLastCalledWith(
|
||||
'error',
|
||||
'tbank.rpc.failed',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
||||
);
|
||||
expect(await result).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
||||
it('stops retrying an unavailable upstream after two RPCs', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||
await Promise.all([
|
||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(6000),
|
||||
jest.advanceTimersByTimeAsync(1000),
|
||||
]);
|
||||
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
|
||||
@@ -46,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0;
|
||||
const NEXT_DAY_OFFSET = 1;
|
||||
|
||||
const RATE_WINDOW_MS = 1_000;
|
||||
const METADATA_MAX_ATTEMPTS = 3;
|
||||
const RPC_MAX_ATTEMPTS = 2;
|
||||
const MAX_QUEUE_SIZE = 50;
|
||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
||||
const RPC_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
const requestDeadlineMs = 15_000;
|
||||
@@ -419,12 +417,6 @@ export class TbankClient {
|
||||
).then((response) => response.lastPrices ?? []);
|
||||
}
|
||||
|
||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
||||
return this.getLastPrices(token, [uid]).then(
|
||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||
return this.call<{ tradingStatus?: string }>(
|
||||
token,
|
||||
@@ -486,8 +478,7 @@ export class TbankClient {
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
const metadataCall = method === 'getInstrumentBy';
|
||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
||||
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
try {
|
||||
@@ -508,34 +499,25 @@ export class TbankClient {
|
||||
const failureContext = queueFull
|
||||
? { code: TbankErrorCode.Unavailable }
|
||||
: { grpcCode };
|
||||
const retryable = queueFull
|
||||
? metadataCall
|
||||
: [
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
status.UNAVAILABLE,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < maxAttempts) {
|
||||
const retryable = !queueFull && [
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
status.UNAVAILABLE,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(
|
||||
resolve,
|
||||
metadataCall
|
||||
? attempts * METADATA_RETRY_DELAY_MS
|
||||
: RPC_RETRY_DELAY_MS,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
this.logger.event('error', 'tbank.rpc.failed', {
|
||||
operation: method,
|
||||
...failureContext,
|
||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
||||
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
if (
|
||||
|
||||
Reference in New Issue
Block a user