refactor: organize backend by business responsibility
This commit is contained in:
@@ -0,0 +1,118 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
|
||||
const metadataForMs = 86_400_000;
|
||||
const maxCachedInstruments = 500;
|
||||
const missingInstrument = Symbol('missingInstrument');
|
||||
|
||||
@Injectable()
|
||||
export class InstrumentMetadataService {
|
||||
private readonly metadata = new LRUCache<
|
||||
string,
|
||||
TbankInstrument | typeof missingInstrument
|
||||
>({
|
||||
max: maxCachedInstruments,
|
||||
ttl: metadataForMs,
|
||||
noDeleteOnStaleGet: true,
|
||||
});
|
||||
private readonly pending = new Map<string, Promise<TbankInstrument | null>>();
|
||||
|
||||
constructor(
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async resolve(
|
||||
token: string,
|
||||
instrumentUid: string | null | undefined,
|
||||
figi: string | null | undefined,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const uid = instrumentUid?.trim();
|
||||
let byUid: TbankInstrument | null = null;
|
||||
if (uid) {
|
||||
byUid = await this.lookup(
|
||||
token,
|
||||
`uid:${uid}`,
|
||||
() => this.tbank.getInstrumentByUid(token, uid),
|
||||
signal,
|
||||
);
|
||||
if (byUid?.name?.trim() || byUid?.ticker?.trim()) return byUid;
|
||||
}
|
||||
const instrumentFigi = figi?.trim();
|
||||
const byFigi = instrumentFigi
|
||||
? await this.lookup(
|
||||
token,
|
||||
`figi:${instrumentFigi}`,
|
||||
() => this.tbank.getInstrumentByFigi(token, instrumentFigi),
|
||||
signal,
|
||||
)
|
||||
: null;
|
||||
return byFigi ?? byUid;
|
||||
}
|
||||
|
||||
private async lookup(
|
||||
token: string,
|
||||
key: string,
|
||||
fetch: () => Promise<TbankInstrument | null>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<TbankInstrument | null> {
|
||||
const cached = this.metadata.get(key);
|
||||
if (cached !== undefined)
|
||||
return cached === missingInstrument ? null : cached;
|
||||
if (signal?.aborted) return this.lastKnown(key);
|
||||
const owner = createHash('sha256').update(token).digest('hex');
|
||||
const requestKey = `${owner}:${key}`;
|
||||
let pending = this.pending.get(requestKey);
|
||||
if (!pending) {
|
||||
pending = this.fetch(key, fetch, this.lastKnown(key)).finally(() => {
|
||||
this.pending.delete(requestKey);
|
||||
});
|
||||
this.pending.set(requestKey, pending);
|
||||
}
|
||||
if (!signal) return pending;
|
||||
return new Promise((resolve, reject) => {
|
||||
const onAbort = () => resolve(this.lastKnown(key));
|
||||
signal.addEventListener('abort', onAbort, { once: true });
|
||||
void pending.then(resolve, reject).finally(() => {
|
||||
signal.removeEventListener('abort', onAbort);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private lastKnown(key: string): TbankInstrument | null {
|
||||
const value = this.metadata.peek(key, { allowStale: true });
|
||||
return value === missingInstrument ? null : (value ?? null);
|
||||
}
|
||||
|
||||
private async fetch(
|
||||
key: string,
|
||||
request: () => Promise<TbankInstrument | null>,
|
||||
previous: TbankInstrument | null,
|
||||
): Promise<TbankInstrument | null> {
|
||||
try {
|
||||
const value = await request();
|
||||
const found = Boolean(value?.name?.trim() || value?.ticker?.trim());
|
||||
if (value === null || found)
|
||||
this.metadata.set(key, value ?? missingInstrument);
|
||||
this.logger.event('debug', 'instrument-metadata.resolved', {
|
||||
found,
|
||||
});
|
||||
return found || value === null ? value : (previous ?? value);
|
||||
} catch (error) {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code:
|
||||
error instanceof Error && 'response' in error
|
||||
? ((error as { response?: { code?: string } }).response?.code ??
|
||||
'UNKNOWN')
|
||||
: 'UNKNOWN',
|
||||
});
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
throw error;
|
||||
return error instanceof ServiceUnavailableException ? previous : null;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user