Files
backend/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts
T
ksv741 0057925888
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
feat: add hourly portfolio sync history
2026-10-09 09:04:41 +03:00

227 lines
8.3 KiB
TypeScript

import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankMembershipService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service';
import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service';
import {
type HourlySection,
TbankSyncRepository,
} from './tbank-sync.repository';
const HOUR_MS = 3_600_000;
const TICK_MS = 60_000;
const WORK_POLL_MS = 100;
const FIRST_DAY_OF_MONTH = 1;
const ONE_ATTEMPT = 1;
const NO_ATTEMPTS = 0;
type SettledWork = { errorCode: null | string; id: string; state: string };
// One native timer per process. The timer only proposes work; the durable
// reservation in TbankSyncRepository is the fence that keeps a single
// attempt per connection. The deployment runs a single backend process.
// ponytail: no inter-process lease; add one before scaling past one process.
@Injectable()
export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
private readonly processRunId = randomUUID();
private timer: NodeJS.Timeout | null = null;
private ticking = false;
constructor(
private readonly prisma: PrismaService,
private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService,
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService,
private readonly events: UpcomingEventsService,
) {}
onModuleInit() {
this.timer = setInterval(() => void this.tick(), TICK_MS);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async tick() {
if (this.ticking) return;
this.ticking = true;
try {
const connections = await this.prisma.tbankConnection.findMany({
where: { invalidToken: false },
select: { userId: true },
});
for (const { userId } of connections) {
await this.launch(userId);
}
} catch {
// A failed tick only delays the proposal until the next tick.
} finally {
this.ticking = false;
}
}
// A fresh connection starts its first walk immediately, regardless of the
// previous term of the replaced connection.
private async launch(userId: string) {
const reserved = await this.repository.reserveHourly(
userId,
this.processRunId,
HOUR_MS,
);
if (!reserved) return null;
const { userId: owner, revision, id, attemptId } = reserved.work;
setImmediate(() => {
void this.walk({ userId: owner, revision, id, attemptId });
});
return reserved.work;
}
// One membership pass, then the owners refresh their own datasets. The
// walk publishes only safe section results and holds no transaction or
// upstream call inside one.
private async walk(attempt: TbankSyncAttempt) {
const failures: HourlySection[] = [];
const attempted = { count: NO_ATTEMPTS };
try {
const { userId, revision } = attempt;
await this.refreshMembership(userId, revision, failures, attempted);
if (failures.some((item) => item.errorCode === TbankErrorCode.InvalidToken)) {
return void this.settle(attempt, failures, attempted.count);
}
const current = await this.access.local(userId);
if (current.revision !== revision) {
return void this.settle(attempt, failures, attempted.count);
}
await this.refreshAccounts(userId, revision, current.accounts, failures, attempted);
await this.settle(attempt, failures, attempted.count);
} catch {
await this.settle(attempt, failures, attempted.count);
}
}
private async refreshMembership(
userId: string,
revision: string,
failures: HourlySection[],
attempted: { count: number },
) {
const member = await this.membership.request(userId, revision, true);
attempted.count += ONE_ATTEMPT;
if (!member) return;
await member.done;
const errorCode = await this.repository.workError(userId, member.id);
if (errorCode) failures.push({ section: 'membership', errorCode });
}
private async refreshAccounts(
userId: string,
revision: string,
accounts: Awaited<ReturnType<TbankAccessService['local']>>['accounts'],
failures: HourlySection[],
attempted: { count: number },
) {
for (const account of accounts) {
await this.refreshPortfolio(userId, revision, account.accountId, failures, attempted);
}
for (const account of accounts) {
await this.refreshOperations(userId, revision, account.accountId, failures, attempted);
}
for (const account of accounts) {
await this.refreshEvents(userId, account.accountId, failures, attempted);
}
}
private async refreshPortfolio(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
const work = await this.portfolio.request(userId, revision, accountId, true);
attempted.count += ONE_ATTEMPT;
if (!work) return;
await work.done;
const errorCode = await this.repository.workError(userId, work.id);
if (errorCode) failures.push({ section: 'portfolio', errorCode, accountId });
}
private async refreshOperations(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
attempted.count += ONE_ATTEMPT;
try {
const id = await this.operations.request(userId, [{ accountId, from: monthStartOf(new Date()), to: new Date() }], true, 'screen', revision);
if (!id) return;
const errorCode = await this.settledWorkError(userId, [id]);
if (errorCode) failures.push({ section: 'operations', errorCode, accountId });
} catch {
failures.push({ section: 'operations', errorCode: TbankErrorCode.Unavailable, accountId });
}
}
private async refreshEvents(userId: string, accountId: string, failures: HourlySection[], attempted: { count: number }) {
attempted.count += ONE_ATTEMPT;
try {
const ids = await this.events.refresh(userId, accountId);
const failed = ids.length ? (await this.settledWorks(userId, ids)).find((row) => row.state === 'failed') : undefined;
if (failed?.errorCode) failures.push({ section: 'events', errorCode: failed.errorCode, accountId });
else await this.events.get(userId, accountId);
} catch {
failures.push({ section: 'events', errorCode: TbankErrorCode.Unavailable, accountId });
}
}
private async settle(
attempt: TbankSyncAttempt,
failures: HourlySection[],
attempted: number,
) {
let result: 'failed' | 'partial' | 'succeeded' = 'succeeded';
if (failures.length) {
result = attempted > NO_ATTEMPTS && failures.length >= attempted ? 'failed' : 'partial';
}
await this.repository
.settleHourly(attempt, result, failures)
.catch(() => undefined);
}
private async settledWorkError(userId: string, ids: string[]) {
const rows = await this.settledWorks(userId, ids);
return rows.find((row) => row.state === 'failed')?.errorCode ?? null;
}
// Polls the durable work rows until they leave the running state; a row
// deleted with its connection counts as interrupted, not failed.
private async settledWorks(userId: string, ids: string[]) {
const settled = new Map<string, SettledWork>();
const pending = new Set(ids);
while (pending.size) {
const rows = await this.repository.works(userId, [...pending]);
for (const row of rows) {
if (row.state !== 'running') {
settled.set(row.id, row);
pending.delete(row.id);
}
}
for (const id of [...pending]) {
if (!rows.some((row) => row.id === id)) {
settled.set(id, { id, state: 'cancelled', errorCode: null });
pending.delete(id);
}
}
if (pending.size) await new Promise((resolve) => setTimeout(resolve, WORK_POLL_MS));
}
return [...settled.values()];
}
}
function monthStartOf(now: Date) {
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH),
);
}