feat!: persist T-Bank data and synchronize reads
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 10m45s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 10m37s
Store account membership, portfolio, operations, forecasts and market sources in SQLite. Coalesce scoped refreshes, fence connection revisions, preserve complete financial snapshots and stabilize operation period state. BREAKING CHANGE: investment reads return data/meta resource envelopes and locally persisted snapshots; consumers must use the matching OpenAPI contract and explicit synchronization endpoints.
This commit is contained in:
@@ -1,118 +1,208 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { LRUCache } from 'lru-cache';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
import {
|
||||
InstrumentMetadataRepository,
|
||||
metadataKey,
|
||||
type MetadataIdentity,
|
||||
type MetadataLookup,
|
||||
} from './instrument-metadata.repository';
|
||||
export { metadataKey } from './instrument-metadata.repository';
|
||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||
|
||||
const metadataForMs = 86_400_000;
|
||||
const maxCachedInstruments = 500;
|
||||
const missingInstrument = Symbol('missingInstrument');
|
||||
|
||||
type Producer = {
|
||||
identity: MetadataIdentity;
|
||||
attempt: TbankSyncAttempt;
|
||||
observers: TbankSyncAttempt[];
|
||||
};
|
||||
@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>>();
|
||||
private readonly runId = randomUUID();
|
||||
private readonly active = new Set<string>();
|
||||
private readonly producers = new Map<string, Producer>();
|
||||
private readonly queue: Producer[] = [];
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly repository: InstrumentMetadataRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
) {}
|
||||
|
||||
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,
|
||||
savedMany(identities: MetadataIdentity[]) {
|
||||
return this.repository.readMany(identities);
|
||||
}
|
||||
|
||||
async read(
|
||||
userId: string,
|
||||
identity: MetadataIdentity,
|
||||
observe = false,
|
||||
revision?: string,
|
||||
) {
|
||||
const connection = await this.access.local(userId);
|
||||
if (revision && connection.revision !== revision) return null;
|
||||
const data = (await this.savedMany([identity])).get(metadataKey(identity));
|
||||
if (!observe && !data?.fresh)
|
||||
await this.request(userId, connection.revision, identity);
|
||||
return {
|
||||
...data,
|
||||
work: await this.repository.work(userId, connection.revision, identity),
|
||||
};
|
||||
}
|
||||
|
||||
async request(
|
||||
userId: string,
|
||||
revision: string,
|
||||
identity: MetadataIdentity,
|
||||
force = false,
|
||||
) {
|
||||
identity = {
|
||||
instrumentUid: identity.instrumentUid?.trim() || null,
|
||||
figi: identity.figi?.trim() || null,
|
||||
};
|
||||
if (!identity.instrumentUid && !identity.figi) return null;
|
||||
const normalized = identity;
|
||||
const reserved = this.reservations.then(async () => {
|
||||
const snapshot = (await this.savedMany([normalized])).get(
|
||||
metadataKey(normalized),
|
||||
);
|
||||
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);
|
||||
});
|
||||
if (!force && snapshot?.fresh) return null;
|
||||
const result = await this.repository.reserve(
|
||||
{ userId, revision },
|
||||
normalized,
|
||||
this.runId,
|
||||
this.active,
|
||||
);
|
||||
if (!result) return null;
|
||||
const { id, attemptId } = result.work;
|
||||
if (!result.launch) return id;
|
||||
const attempt = { userId, revision, id, attemptId };
|
||||
this.active.add(attemptId);
|
||||
const key = metadataKey(normalized);
|
||||
const producer = this.producers.get(key);
|
||||
if (producer) producer.observers.push(attempt);
|
||||
else {
|
||||
const queued = { identity: normalized, attempt, observers: [] };
|
||||
this.producers.set(key, queued);
|
||||
this.queue.push(queued);
|
||||
}
|
||||
if (!this.running) {
|
||||
this.running = true;
|
||||
setImmediate(() => {
|
||||
void this.drain();
|
||||
});
|
||||
}
|
||||
return id;
|
||||
});
|
||||
this.reservations = reserved.catch(() => undefined);
|
||||
return reserved;
|
||||
}
|
||||
|
||||
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> {
|
||||
private async drain() {
|
||||
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,
|
||||
while (this.queue.length) {
|
||||
const producer = this.queue.shift();
|
||||
if (!producer) break;
|
||||
const { attempt, identity } = producer;
|
||||
let success = false;
|
||||
try {
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (token) {
|
||||
const results: MetadataLookup[] = [];
|
||||
let useful = false;
|
||||
let uidFailed = false;
|
||||
if (identity.instrumentUid) {
|
||||
let data: TbankInstrument | null | undefined;
|
||||
try {
|
||||
data = await this.tbank.getInstrumentByUid(
|
||||
token,
|
||||
identity.instrumentUid,
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
!identity.figi ||
|
||||
(error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN')
|
||||
)
|
||||
throw error;
|
||||
uidFailed = true;
|
||||
}
|
||||
if (data?.uid && data.uid !== identity.instrumentUid)
|
||||
throw new Error('Metadata UID mismatch');
|
||||
if (data !== undefined)
|
||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
|
||||
if (!useful && data && !identity.figi)
|
||||
throw new Error('Incomplete metadata');
|
||||
}
|
||||
if (!useful && identity.figi) {
|
||||
const data = await this.tbank.getInstrumentByFigi(
|
||||
token,
|
||||
identity.figi,
|
||||
);
|
||||
if (data?.figi && data.figi !== identity.figi)
|
||||
throw new Error('Metadata FIGI mismatch');
|
||||
if (data && !data.name?.trim() && !data.ticker?.trim())
|
||||
throw new Error('Incomplete metadata');
|
||||
if (data === undefined || (uidFailed && !data))
|
||||
throw new Error('Metadata source unavailable');
|
||||
results.push({ type: 'figi', id: identity.figi, data });
|
||||
// A successful FIGI response identifies its canonical UID too;
|
||||
// a failed UID RPC is never persisted as a negative lookup.
|
||||
if (
|
||||
uidFailed &&
|
||||
data?.uid === identity.instrumentUid &&
|
||||
identity.instrumentUid
|
||||
)
|
||||
results.push({ type: 'uid', id: identity.instrumentUid, data });
|
||||
}
|
||||
if (
|
||||
!results.length ||
|
||||
(results.some((result) => result.data) &&
|
||||
!results.some(
|
||||
(result) =>
|
||||
result.data?.name?.trim() || result.data?.ticker?.trim(),
|
||||
))
|
||||
)
|
||||
throw new Error('Incomplete metadata');
|
||||
success = await this.repository.publish(
|
||||
attempt,
|
||||
results,
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
else
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
// Remove the producer before awaiting finalization: a new request cannot
|
||||
// attach to a producer whose observer list has already been drained.
|
||||
this.producers.delete(metadataKey(identity));
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
await this.repository.finishObserver(observer, success);
|
||||
this.active.delete(observer.attemptId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
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;
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
this.queue.length = 0;
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user