refactor!: simplify runtime coordination #7
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s

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:
2026-10-03 11:38:08 +03:00
parent 0f27da9fd1
commit d698ed9425
48 changed files with 185 additions and 925 deletions
@@ -1,11 +1,9 @@
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
@@ -19,7 +17,6 @@ import {
type TbankPortfolio,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { CandleService } from '../../instruments/instruments.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
@@ -38,23 +35,19 @@ const membershipFreshForMs = tbankSourceTtlMs.membership;
@Injectable()
export class PortfolioService {
private readonly processRunId = randomUUID();
private readonly active = new Set<string>();
private readonly pending = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger,
private readonly candles: CandleService,
) {}
async saved(userId: string, accountId: string, observe = false) {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const connection = await this.access.local(userId, accountId);
if (!observe) {
const discovery = await this.membership.request(
@@ -123,28 +116,6 @@ export class PortfolioService {
}
}
}
if (!observe) {
void this.candles
.prefetch(
userId,
connection.revision,
(saved?.accounts ?? [])
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap(
(account) =>
(
account.portfolio?.payload as unknown as
PortfolioDto | undefined
)?.positions ?? [],
),
)
.catch(() =>
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
);
}
const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
@@ -319,7 +290,7 @@ export class PortfolioService {
revision,
accountId,
this.processRunId,
this.active,
this.pending,
force,
);
if (!requested) return null;
@@ -330,7 +301,6 @@ export class PortfolioService {
};
}
const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(async () => {
const attempt = { id, attemptId, userId, revision };
@@ -354,13 +324,6 @@ export class PortfolioService {
for (const position of snapshot.positions) {
await this.metadata.request(userId, revision, position);
}
if (!force) {
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
}
} catch (error) {
if (
@@ -377,7 +340,6 @@ export class PortfolioService {
}),
)
.finally(() => {
this.active.delete(attemptId);
this.pending.delete(attemptId);
});
this.pending.set(attemptId, done);
@@ -387,18 +349,6 @@ export class PortfolioService {
return reserved;
}
async get(userId: string, accountId: string): Promise<PortfolioDto> {
const result = await this.read(userId, accountId);
if (!result.data) {
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
return result.data;
}
getAll(userId: string): Promise<PortfolioDto> {
return this.get(userId, 'all');
}
private async normalize(
upstream: NonNullable<TbankPortfolio['positions']>,
money: TbankMoney[],