Merge pull request 'fix: restore terminal hourly lifecycle after persistence failure (#56)' (#77) from fix/56-harden-hourly-sync-lifecycle into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m10s

Reviewed-on: #77
Reviewed-by: hermes-agent <hermes@ksv741.ru>
This commit was merged in pull request #77.
This commit is contained in:
2026-10-12 21:59:27 +03:00
5 changed files with 921 additions and 112 deletions
@@ -58,7 +58,7 @@ export class CatalogEnrichmentService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ done: Promise<void>; resolve: () => void; workId: string } { attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
>(); >();
async ensureDetail( async ensureDetail(
@@ -208,7 +208,7 @@ export class CatalogEnrichmentService {
const done = new Promise<void>((finish) => { const done = new Promise<void>((finish) => {
resolve = finish; resolve = finish;
}); });
this.completions.set(attemptId, { workId: id, done, resolve }); this.completions.set(attemptId, { attempt, done, resolve, workId: id });
const key = producerKey(input); const key = producerKey(input);
const producer = this.producers.get(key); const producer = this.producers.get(key);
if (producer) producer.observers.push({ attempt, input }); if (producer) producer.observers.push({ attempt, input });
@@ -580,6 +580,14 @@ export class CatalogEnrichmentService {
} }
} }
} catch { } catch {
// Persistence failed mid-batch: finish every still-open observer
// attempt durably so no source row is left running. Best effort — a
// row that stays open is re-reserved by the next reader.
for (const completion of this.completions.values()) {
await this.repository
.finish(completion.attempt, false)
.catch(() => undefined);
}
for (const completion of this.completions.values()) completion.resolve(); for (const completion of this.completions.values()) completion.resolve();
this.completions.clear(); this.completions.clear();
this.active.clear(); this.active.clear();
@@ -209,6 +209,12 @@ export class BrokerageOperationsSyncService {
'tbank.operations.sync.persistence_failed', 'tbank.operations.sync.persistence_failed',
{ userId: attempt.userId, code: TbankErrorCode.Unavailable }, { userId: attempt.userId, code: TbankErrorCode.Unavailable },
); );
// The step could not even record its failure; close the attempt
// durably so it does not stay running. Best effort — an open row
// is re-reserved by the next request.
await this.repository
.fail(attempt, false)
.catch(() => undefined);
} }
const next = pending ? await this.repository.pending(attempt) : null; const next = pending ? await this.repository.pending(attempt) : null;
if (next) { if (next) {
@@ -1,6 +1,7 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
@@ -12,6 +13,9 @@ import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service'; import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service';
import { import {
HOURLY_CAUSE_INTERRUPTED,
type HourlyCause,
type HourlyChild,
type HourlySection, type HourlySection,
TbankSyncRepository, TbankSyncRepository,
} from './tbank-sync.repository'; } from './tbank-sync.repository';
@@ -19,11 +23,16 @@ import {
const HOUR_MS = 3_600_000; const HOUR_MS = 3_600_000;
const TICK_MS = 60_000; const TICK_MS = 60_000;
const WORK_POLL_MS = 100; const WORK_POLL_MS = 100;
const WORK_POLL_BACKOFF_FACTOR = 2;
const WORK_POLL_MAX_MS = 5_000;
// ponytail: fixed 15-minute step wait bound (900_000 ms), no config knob;
// make it configurable if slow steps become regular.
const WORK_WAIT_BOUND_MS = 900_000;
const FIRST_DAY_OF_MONTH = 1; const FIRST_DAY_OF_MONTH = 1;
const ONE_ATTEMPT = 1; const NO_MILLISECONDS = 0;
const NO_ATTEMPTS = 0;
type SettledWork = { errorCode: null | string; id: string; state: string }; type DispatchStep = (child: HourlyChild) => Promise<void>;
type Accounts = Awaited<ReturnType<TbankAccessService['local']>>['accounts'];
// One native timer per process. The timer only proposes work; the durable // One native timer per process. The timer only proposes work; the durable
// reservation in TbankSyncRepository is the fence that keeps a single // reservation in TbankSyncRepository is the fence that keeps a single
@@ -32,6 +41,9 @@ type SettledWork = { errorCode: null | string; id: string; state: string };
@Injectable() @Injectable()
export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit { export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
private readonly processRunId = randomUUID(); private readonly processRunId = randomUUID();
// Attempt ids of walks currently executing in this process; a running row
// outside this set lost its settle and is closed by recovery.
private readonly active = new Set<string>();
private timer: NodeJS.Timeout | null = null; private timer: NodeJS.Timeout | null = null;
private ticking = false; private ticking = false;
@@ -43,6 +55,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
private readonly portfolio: PortfolioService, private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService, private readonly operations: BrokerageOperationsSyncService,
private readonly events: UpcomingEventsService, private readonly events: UpcomingEventsService,
private readonly logger: StructuredLogger,
) {} ) {}
onModuleInit() { onModuleInit() {
@@ -74,6 +87,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
// A fresh connection starts its first walk immediately, regardless of the // A fresh connection starts its first walk immediately, regardless of the
// previous term of the replaced connection. // previous term of the replaced connection.
private async launch(userId: string) { private async launch(userId: string) {
await this.recoverAbandoned(userId);
const reserved = await this.repository.reserveHourly( const reserved = await this.repository.reserveHourly(
userId, userId,
this.processRunId, this.processRunId,
@@ -87,135 +101,263 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
return reserved.work; return reserved.work;
} }
// One membership pass, then the owners refresh their own datasets. The // A running hourly row of this process that has no active walk lost its
// walk publishes only safe section results and holds no transaction or // settle (suppressed persistence error). Close it from its durable step
// upstream call inside one. // results; rows of other processes are closed by the reservation restart
private async walk(attempt: TbankSyncAttempt) { // branch. Rides the existing tick: no new timers or sanitors.
const failures: HourlySection[] = []; private async recoverAbandoned(userId: string) {
const attempted = { count: NO_ATTEMPTS }; const rows = await this.repository.runningHourly(userId);
try { for (const row of rows) {
const { userId, revision } = attempt; if (row.processRunId !== this.processRunId) continue;
await this.refreshMembership(userId, revision, failures, attempted); if (this.active.has(row.attemptId)) continue;
if (failures.some((item) => item.errorCode === TbankErrorCode.InvalidToken)) { await this.repository
return void this.settle(attempt, failures, attempted.count); .settleHourly(
} {
const current = await this.access.local(userId); userId,
if (current.revision !== revision) { revision: row.revision,
return void this.settle(attempt, failures, attempted.count); id: row.id,
} attemptId: row.attemptId,
await this.refreshAccounts(userId, revision, current.accounts, failures, attempted); },
await this.settle(attempt, failures, attempted.count); HOURLY_CAUSE_INTERRUPTED,
} catch { )
await this.settle(attempt, failures, attempted.count); .catch(() => undefined);
} }
} }
// 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. The attempt outcome is derived from durable
// step rows by the repository, never from walk-local state.
private async walk(attempt: TbankSyncAttempt) {
this.active.add(attempt.attemptId);
try {
const { userId, revision } = attempt;
const children: HourlyChild[] = [];
// Required sections whose dispatch failed never get a durable step
// row; they are recorded as problems so the attempt cannot succeed.
const dispatchProblems: HourlySection[] = [];
const dispatch: DispatchStep = async (child) => {
children.push(child);
await this.repository.recordHourlyChild(attempt, child);
};
const accounts = await this.refreshMembership(
attempt,
dispatch,
dispatchProblems,
);
if (!accounts) return;
await this.refreshAccounts(
userId,
revision,
accounts,
dispatch,
dispatchProblems,
);
await this.waitWorks(userId, children.map((child) => child.id));
for (const account of accounts) {
await this.events.get(userId, account.accountId).catch(() => undefined);
}
await this.settle(attempt, null, dispatchProblems);
} catch {
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
} finally {
this.active.delete(attempt.attemptId);
}
}
// Returns the current accounts, or null when the attempt is already
// settled (invalid token or a replaced connection revision).
private async refreshMembership( private async refreshMembership(
userId: string, attempt: TbankSyncAttempt,
revision: string, dispatch: DispatchStep,
failures: HourlySection[], dispatchProblems: HourlySection[],
attempted: { count: number },
) { ) {
const { userId, revision } = attempt;
const member = await this.membership.request(userId, revision, true); const member = await this.membership.request(userId, revision, true);
attempted.count += ONE_ATTEMPT; if (member) {
if (!member) return; await dispatch({ id: member.id, section: 'membership' });
await member.done; await member.done;
const errorCode = await this.repository.workError(userId, member.id); if (
if (errorCode) failures.push({ section: 'membership', errorCode }); (await this.repository.workError(userId, member.id)) ===
TbankErrorCode.InvalidToken
) {
await this.settle(attempt);
return null;
}
} else {
dispatchProblems.push({
section: 'membership',
errorCode: TbankErrorCode.Unavailable,
});
}
const current = await this.access.local(userId);
if (current.revision !== revision) {
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
return null;
}
return current.accounts;
} }
private async refreshAccounts( private async refreshAccounts(
userId: string, userId: string,
revision: string, revision: string,
accounts: Awaited<ReturnType<TbankAccessService['local']>>['accounts'], accounts: Accounts,
failures: HourlySection[], dispatch: DispatchStep,
attempted: { count: number }, dispatchProblems: HourlySection[],
) { ) {
for (const account of accounts) { for (const account of accounts) {
await this.refreshPortfolio(userId, revision, account.accountId, failures, attempted); await this.dispatchPortfolio(
userId,
revision,
account.accountId,
dispatch,
dispatchProblems,
);
} }
for (const account of accounts) { for (const account of accounts) {
await this.refreshOperations(userId, revision, account.accountId, failures, attempted); await this.dispatchOperations(
userId,
revision,
account.accountId,
dispatch,
dispatchProblems,
);
} }
for (const account of accounts) { for (const account of accounts) {
await this.refreshEvents(userId, account.accountId, failures, attempted); await this.dispatchEvents(
userId,
account.accountId,
dispatch,
dispatchProblems,
);
} }
} }
private async refreshPortfolio(userId: string, revision: string, accountId: string, failures: HourlySection[], attempted: { count: number }) { private async dispatchPortfolio(
const work = await this.portfolio.request(userId, revision, accountId, true); userId: string,
attempted.count += ONE_ATTEMPT; revision: string,
if (!work) return; accountId: string,
await work.done; dispatch: DispatchStep,
const errorCode = await this.repository.workError(userId, work.id); dispatchProblems: HourlySection[],
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 { try {
const id = await this.operations.request(userId, [{ accountId, from: monthStartOf(new Date()), to: new Date() }], true, 'screen', revision); const work = await this.portfolio.request(userId, revision, accountId, true);
if (!id) return; if (work) {
const errorCode = await this.settledWorkError(userId, [id]); await dispatch({ accountId, id: work.id, section: 'portfolio' });
if (errorCode) failures.push({ section: 'operations', errorCode, accountId }); return;
}
} catch { } catch {
failures.push({ section: 'operations', errorCode: TbankErrorCode.Unavailable, accountId }); // A failed dispatch has no durable step row; record the problem.
}
dispatchProblems.push({
accountId,
errorCode: TbankErrorCode.Unavailable,
section: 'portfolio',
});
}
private async dispatchOperations(
userId: string,
revision: string,
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
try {
const id = await this.operations.request(
userId,
[
{
accountId,
from: monthStartOf(new Date()),
to: new Date(),
},
],
true,
'screen',
revision,
);
if (id) await dispatch({ accountId, id, section: 'operations' });
} catch {
dispatchProblems.push({
accountId,
errorCode: TbankErrorCode.Unavailable,
section: 'operations',
});
} }
} }
private async refreshEvents(userId: string, accountId: string, failures: HourlySection[], attempted: { count: number }) { private async dispatchEvents(
attempted.count += ONE_ATTEMPT; userId: string,
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
try { try {
const ids = await this.events.refresh(userId, accountId); const ids = await this.events.refresh(userId, accountId);
const failed = ids.length ? (await this.settledWorks(userId, ids)).find((row) => row.state === 'failed') : undefined; for (const id of ids) {
if (failed?.errorCode) failures.push({ section: 'events', errorCode: failed.errorCode, accountId }); await dispatch({ accountId, id, section: 'events' });
else await this.events.get(userId, accountId); }
} catch { } catch {
failures.push({ section: 'events', errorCode: TbankErrorCode.Unavailable, accountId }); dispatchProblems.push({
accountId,
errorCode: TbankErrorCode.Unavailable,
section: 'events',
});
} }
} }
private async settle( private async settle(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
failures: HourlySection[], cause: HourlyCause | null = null,
attempted: number, dispatchProblems: HourlySection[] = [],
) { ) {
let result: 'failed' | 'partial' | 'succeeded' = 'succeeded'; // A suppressed settle error is not lost: the walk leaves the active set
if (failures.length) { // and the next tick closes the attempt from its durable step results.
result = attempted > NO_ATTEMPTS && failures.length >= attempted ? 'failed' : 'partial';
}
await this.repository await this.repository
.settleHourly(attempt, result, failures) .settleHourly(attempt, cause, dispatchProblems)
.catch(() => undefined); .catch(() => {
this.logger.event('error', 'tbank.hourly.settle_failed', {
userId: attempt.userId,
});
return undefined;
});
} }
private async settledWorkError(userId: string, ids: string[]) { // Drops settled and missing rows from the wait set and expires waits that
const rows = await this.settledWorks(userId, ids); // passed the per-step bound.
return rows.find((row) => row.state === 'failed')?.errorCode ?? null; private advanceWaiting(
waiting: Map<string, number>,
rows: { id: string; state: string }[],
) {
for (const row of rows) {
if (row.state !== 'running') waiting.delete(row.id);
}
for (const id of [...waiting.keys()]) {
if (!rows.some((row) => row.id === id)) {
waiting.delete(id);
continue;
}
const startedAt = waiting.get(id) ?? NO_MILLISECONDS;
if (Date.now() - startedAt >= WORK_WAIT_BOUND_MS) waiting.delete(id);
}
} }
// Polls the durable work rows until they leave the running state; a row // Waits for every dispatched step to leave the running state. Each wait is
// deleted with its connection counts as interrupted, not failed. // bounded so a stuck step cannot hold the attempt running forever; the
private async settledWorks(userId: string, ids: string[]) { // bound does not cancel the upstream call, and late results stay in the
const settled = new Map<string, SettledWork>(); // step row where the attempt fence decides whether they count.
const pending = new Set(ids); private async waitWorks(userId: string, ids: string[]) {
while (pending.size) { const waiting = new Map(ids.map((id) => [id, Date.now()]));
const rows = await this.repository.works(userId, [...pending]); let pollMs = WORK_POLL_MS;
for (const row of rows) { while (waiting.size) {
if (row.state !== 'running') { const rows = await this.repository.works(userId, [...waiting.keys()]);
settled.set(row.id, row); this.advanceWaiting(waiting, rows);
pending.delete(row.id); if (waiting.size) {
} await new Promise((resolve) => setTimeout(resolve, pollMs));
} pollMs = Math.min(pollMs * WORK_POLL_BACKOFF_FACTOR, WORK_POLL_MAX_MS);
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()];
} }
} }
@@ -18,7 +18,19 @@ export type HourlySection = {
errorCode: null | string; errorCode: null | string;
section: 'events' | 'membership' | 'operations' | 'portfolio'; section: 'events' | 'membership' | 'operations' | 'portfolio';
}; };
export type HourlyChild = {
accountId?: string;
id: string;
section: HourlySection['section'];
};
export const HOURLY_CAUSE_DEADLINE = 'DEADLINE';
export const HOURLY_CAUSE_INTERRUPTED = 'INTERRUPTED';
export type HourlyCause = typeof HOURLY_CAUSE_INTERRUPTED;
type StepOutcome = { problem?: HourlySection; success: boolean };
type StepRow = { attemptId: string; errorCode: null | string; state: string };
export type HourlyProgress = { export type HourlyProgress = {
attempts?: Record<string, string>;
children?: HourlyChild[];
result?: 'failed' | 'partial' | 'succeeded'; result?: 'failed' | 'partial' | 'succeeded';
sections: HourlySection[]; sections: HourlySection[];
}; };
@@ -191,12 +203,10 @@ export class TbankSyncRepository {
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs; const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
if (previous.state === 'running') { if (previous.state === 'running') {
if (previous.processRunId === runId) return null; if (previous.processRunId === runId) return null;
// A dead process cannot finish its attempt; interrupt it, but its // A dead process cannot finish its attempt; close it from its
// start stays the basis of the next term. // actual step results, but its start stays the basis of the next
await tx.tbankSyncWork.update({ // term.
where: { userId_revision_scopeKey: key }, await this.closeHourly(tx, previous, 'INTERRUPTED', []);
data: { state: 'cancelled', errorCode: null },
});
if (!due) return null; if (!due) return null;
} else if (!due) return null; } else if (!due) return null;
} }
@@ -220,28 +230,146 @@ export class TbankSyncRepository {
}); });
} }
settleHourly( private classifyStep(
attempt: TbankSyncAttempt, child: HourlyChild,
result: 'failed' | 'partial' | 'succeeded', row: StepRow | undefined,
recorded: string | undefined,
cause: HourlyCause | null,
): StepOutcome {
if (row && row.attemptId === recorded && row.state === 'complete') {
return { success: true };
}
const stale = !row || (recorded !== undefined && row.attemptId !== recorded);
let errorCode: null | string = null;
if (!stale && row.state === 'failed') errorCode = row.errorCode;
else if (!stale && cause === HOURLY_CAUSE_INTERRUPTED) {
errorCode = HOURLY_CAUSE_INTERRUPTED;
} else if (!stale) errorCode = HOURLY_CAUSE_DEADLINE;
return {
problem: { accountId: child.accountId, errorCode, section: child.section },
success: false,
};
}
private deriveResult(
cause: HourlyCause | null,
sections: HourlySection[], sections: HourlySection[],
successes: number,
): 'failed' | 'partial' | 'succeeded' {
if (!cause && !sections.length) return 'succeeded';
return successes ? 'partial' : 'failed';
}
// The outcome is derived from the durable state of the dispatched steps,
// never from an in-memory error list that an exception path can empty.
// `cause` marks a close before normal completion and caps the result at
// `partial`; `dispatchProblems` records required sections that never got a
// durable step row because their dispatch failed.
private async closeHourly(
tx: Prisma.TransactionClient,
work: {
attemptId: string;
id: string;
progress: Prisma.JsonValue;
revision: string;
userId: string;
},
cause: HourlyCause | null,
dispatchProblems: HourlySection[],
) { ) {
return this.prisma.tbankSyncWork.updateMany({ const progress = work.progress as HourlyProgress;
where: { const children = progress.children ?? [];
id: attempt.id, const rows = children.length
attemptId: attempt.attemptId, ? await tx.tbankSyncWork.findMany({
userId: attempt.userId, where: {
revision: attempt.revision, userId: work.userId,
state: 'running', id: { in: children.map((child) => child.id) },
}, },
select: { attemptId: true, errorCode: true, id: true, state: true },
})
: [];
const byId = new Map(rows.map((row) => [row.id, row]));
const outcomes = children.map((child) =>
this.classifyStep(
child,
byId.get(child.id),
progress.attempts?.[child.id],
cause,
),
);
const sections = outcomes
.flatMap((outcome) => (outcome.problem ? [outcome.problem] : []))
.concat(dispatchProblems);
const successes = outcomes.filter((outcome) => outcome.success).length;
const result = this.deriveResult(cause, sections, successes);
return tx.tbankSyncWork.updateMany({
where: { id: work.id, attemptId: work.attemptId, state: 'running' },
data: { data: {
state: result === 'failed' ? 'failed' : 'complete', state: result === 'failed' ? 'failed' : 'complete',
errorCode: errorCode:
sections.find((section) => section.errorCode)?.errorCode ?? null, cause ??
progress: { result, sections }, sections.find((section) => section.errorCode)?.errorCode ??
null,
progress: {
attempts: progress.attempts ?? {},
children,
result,
sections,
},
}, },
}); });
} }
async settleHourly(
attempt: TbankSyncAttempt,
cause: HourlyCause | null = null,
dispatchProblems: HourlySection[] = [],
) {
return this.prisma.$transaction(async (tx) => {
const work = await tx.tbankSyncWork.findFirst({ where: attempt });
if (!work || work.state !== 'running') return { count: 0 };
return this.closeHourly(tx, work, cause, dispatchProblems);
});
}
// Records a dispatched step on the attempt before it is awaited, so a lost
// walk can still derive its outcome from durable rows.
async recordHourlyChild(attempt: TbankSyncAttempt, child: HourlyChild) {
return this.prisma.$transaction(async (tx) => {
const work = await tx.tbankSyncWork.findFirst({
where: { ...attempt, state: 'running' },
select: { progress: true },
});
if (!work) return false;
const progress = work.progress as HourlyProgress;
const row = await tx.tbankSyncWork.findFirst({
where: { userId: attempt.userId, id: child.id },
select: { attemptId: true },
});
const updated = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: {
progress: {
attempts: {
...progress.attempts,
...(row ? { [child.id]: row.attemptId } : {}),
},
children: [...(progress.children ?? []), child],
sections: progress.sections ?? [],
},
},
});
return Boolean(updated.count);
});
}
runningHourly(userId: string) {
return this.prisma.tbankSyncWork.findMany({
where: { userId, scopeKey: 'hourly', state: 'running' },
select: { attemptId: true, id: true, processRunId: true, revision: true },
});
}
async hourly(userId: string) { async hourly(userId: string) {
const connection = await this.prisma.tbankConnection.findFirst({ const connection = await this.prisma.tbankConnection.findFirst({
where: { userId }, where: { userId },
+525
View File
@@ -0,0 +1,525 @@
import { ConfigService } from '@nestjs/config';
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { setImmediate as immediate } from 'node:timers/promises';
import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service';
import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository';
import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service';
import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { TbankSyncRepository } from '../src/modules/investments/tbank-sync/tbank-sync.repository';
import { PrismaService } from '../src/prisma/prisma.service';
type HourlyProgressView = {
result?: string;
sections: { accountId?: string; errorCode: null | string; section: string }[];
};
// Real SQLite proves reservation, outcome derivation, recovery and fences;
// the step owners are controlled so each failure mode can be injected.
describe('hourly attempt lifecycle (SQLite)', () => {
const prisma = new PrismaService(
new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }),
);
const tokens = new TbankTokenService(
new ConfigService({
TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY,
}) as never,
);
const repository = new TbankSyncRepository(prisma);
const access = { local: jest.fn() };
const membership = { request: jest.fn() };
const portfolio = { request: jest.fn() };
const operations = { request: jest.fn() };
const events = { refresh: jest.fn(), get: jest.fn() };
const logger = { event: jest.fn() };
const createScheduler = () =>
new TbankHourlyScheduler(
prisma,
repository,
access as never,
membership as never,
portfolio as never,
operations as never,
events as never,
logger as never,
);
const USER = 'hourly-owner';
const REVISION = 'hourly-revision';
function child(
state: 'complete' | 'failed' | 'running',
errorCode: null | string,
scopeKey: string,
) {
return prisma.tbankSyncWork.create({
data: {
id: randomUUID(),
userId: USER,
revision: REVISION,
scopeKey: `test:${scopeKey}:${randomUUID()}`,
attemptId: randomUUID(),
processRunId: 'child-process',
priority: 0,
intent: {},
progress: {},
state,
errorCode,
},
});
}
function hourlyRow() {
return prisma.tbankSyncWork.findFirst({
where: { userId: USER, scopeKey: 'hourly' },
});
}
async function pump(turns = 20) {
for (let i = 0; i < turns; i++) {
await immediate();
await jest.advanceTimersByTimeAsync(50);
}
}
async function runWalk(scheduler: TbankHourlyScheduler, advanceMs = 50) {
await scheduler.tick();
for (let i = 0; i < 400; i++) {
await immediate();
await jest.advanceTimersByTimeAsync(advanceMs);
const row = await hourlyRow();
if (row && row.state !== 'running') return row;
}
throw new Error('hourly attempt did not settle');
}
beforeAll(async () => {
await prisma.$connect();
await prisma.user.create({
data: { id: USER, email: 'hourly@example.invalid', passwordHash: 'x' },
});
});
afterAll(async () => prisma.$disconnect());
beforeEach(async () => {
jest.resetAllMocks();
await prisma.tbankSyncWork.deleteMany();
await prisma.tbankConnection.deleteMany();
await prisma.tbankConnection.create({
data: {
userId: USER,
revision: REVISION,
encryptedToken: tokens.encrypt('synthetic'),
connectedAt: new Date(),
},
});
jest.useFakeTimers({
doNotFake: ['queueMicrotask', 'setImmediate', 'nextTick'],
});
access.local.mockResolvedValue({
revision: REVISION,
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
});
membership.request.mockImplementation(async () => {
const row = await child('complete', null, 'membership');
return { id: row.id, done: Promise.resolve() };
});
portfolio.request.mockImplementation(
async (_userId: string, _revision: string, accountId: string) => {
const row = await child('complete', null, `portfolio:${accountId}`);
return { id: row.id, done: Promise.resolve() };
},
);
operations.request.mockImplementation(async () => {
const row = await child('complete', null, 'operations');
return row.id;
});
events.refresh.mockResolvedValue([]);
events.get.mockResolvedValue(undefined);
});
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
it('settles the first walk succeeded only when every step row completed', async () => {
const row = await runWalk(createScheduler());
expect(row.state).toBe('complete');
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('succeeded');
expect(progress.sections).toEqual([]);
const read = await repository.hourly(USER);
expect(read?.state).toBe('succeeded');
});
it('settles partial and keeps successful steps when one account fails', async () => {
portfolio.request.mockImplementation(
async (_userId: string, _revision: string, accountId: string) => {
const failed = accountId === 'b';
const row = await child(
failed ? 'failed' : 'complete',
failed ? 'TBANK_UNAVAILABLE' : null,
`portfolio:${accountId}`,
);
return { id: row.id, done: Promise.resolve() };
},
);
const row = await runWalk(createScheduler());
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('partial');
expect(progress.sections).toHaveLength(1);
expect(progress.sections[0].section).toBe('portfolio');
expect(progress.sections[0].accountId).toBe('b');
expect(progress.sections[0].errorCode).toBe('TBANK_UNAVAILABLE');
});
it('settles failed when the membership step fails before any update', async () => {
membership.request.mockImplementation(async () => {
const row = await child('failed', 'INVALID_TBANK_TOKEN', 'membership');
return { id: row.id, done: Promise.resolve() };
});
const row = await runWalk(createScheduler());
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('failed');
expect(row.errorCode).toBe('INVALID_TBANK_TOKEN');
expect(progress.sections[0].section).toBe('membership');
});
it('never succeeds when no required step succeeded', async () => {
membership.request.mockImplementation(async () => {
const row = await child('failed', 'TBANK_UNAVAILABLE', 'membership');
return { id: row.id, done: Promise.resolve() };
});
portfolio.request.mockImplementation(
async (_userId: string, _revision: string, accountId: string) => {
const row = await child(
'failed',
'TBANK_UNAVAILABLE',
`portfolio:${accountId}`,
);
return { id: row.id, done: Promise.resolve() };
},
);
operations.request.mockRejectedValue(new Error('db'));
events.refresh.mockRejectedValue(new Error('db'));
const row = await runWalk(createScheduler());
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('failed');
expect(progress.result).not.toBe('succeeded');
assert.ok(progress.sections.length > 0);
});
it('caps the outcome at partial when the coordinator itself breaks', async () => {
access.local.mockRejectedValueOnce(new Error('db'));
const row = await runWalk(createScheduler());
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('partial');
expect(row.errorCode).toBe('INTERRUPTED');
});
it('records a section whose dispatch failed as a problem', async () => {
events.refresh.mockRejectedValue(new Error('db'));
const row = await runWalk(createScheduler());
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('partial');
const eventsSection = progress.sections.find(
(entry) => entry.section === 'events',
);
assert.ok(eventsSection);
expect(eventsSection.errorCode).toBe('TBANK_UNAVAILABLE');
expect(row.errorCode).toBe('TBANK_UNAVAILABLE');
});
it('closes a stuck step as DEADLINE and keeps its row running', async () => {
operations.request.mockImplementation(async () => {
const row = await child('running', null, 'operations');
return row.id;
});
const scheduler = createScheduler();
await scheduler.tick();
await pump();
await jest.advanceTimersByTimeAsync(16 * 60 * 1000);
await pump(20);
const row = await hourlyRow();
assert.ok(row);
expect(row.state).not.toBe('running');
const progress = row.progress as HourlyProgressView;
expect(progress.result).toBe('partial');
assert.ok(
progress.sections.some(
(entry) =>
entry.section === 'operations' && entry.errorCode === 'DEADLINE',
),
);
const stuck = await prisma.tbankSyncWork.findFirst({
where: { userId: USER, state: 'running' },
});
assert.ok(stuck, 'the wait bound does not cancel the upstream call');
});
it('recovers a lost settle and unblocks the next attempt at its term', async () => {
const settleSpy = jest
.spyOn(repository, 'settleHourly')
.mockRejectedValueOnce(new Error('db'));
const scheduler = createScheduler();
await scheduler.tick();
for (let i = 0; i < 40 && settleSpy.mock.calls.length === 0; i++) {
await immediate();
await jest.advanceTimersByTimeAsync(50);
}
expect(settleSpy.mock.calls).toHaveLength(1);
const open = await hourlyRow();
expect(open?.state).toBe('running');
await pump(20);
await scheduler.tick();
await pump(20);
const closed = await hourlyRow();
expect(closed?.state).toBe('complete');
expect((closed?.progress as HourlyProgressView).result).toBe('partial');
expect(closed?.errorCode).toBe('INTERRUPTED');
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
await scheduler.tick();
await pump(30);
const next = await hourlyRow();
expect(next?.attemptId).not.toBe(closed?.attemptId);
expect(next?.state).not.toBe('running');
expect((next?.progress as HourlyProgressView).result).toBe('succeeded');
});
it('closes an abandoned foreign-process attempt and keeps the normal term', async () => {
const stuck = await child('complete', null, 'membership');
const deadAttemptId = randomUUID();
await prisma.tbankSyncWork.create({
data: {
id: randomUUID(),
userId: USER,
revision: REVISION,
scopeKey: 'hourly',
attemptId: deadAttemptId,
processRunId: 'dead-process',
priority: 0,
intent: {},
state: 'running',
progress: {
children: [{ id: stuck.id, section: 'membership' }],
attempts: { [stuck.id]: stuck.attemptId },
sections: [],
},
},
});
const scheduler = createScheduler();
await runWalk(scheduler);
const closed = await hourlyRow();
expect(closed?.attemptId).toBe(deadAttemptId);
expect(closed?.state).not.toBe('running');
expect((closed?.progress as HourlyProgressView).result).toBe('partial');
expect(closed?.errorCode).toBe('INTERRUPTED');
const hourlyCount = await prisma.tbankSyncWork.count({
where: { userId: USER, scopeKey: 'hourly' },
});
expect(hourlyCount).toBe(1);
await jest.advanceTimersByTimeAsync(61 * 60 * 1000);
await scheduler.tick();
await pump(30);
const next = await hourlyRow();
expect(next?.attemptId).not.toBe(deadAttemptId);
expect(next?.state).not.toBe('running');
});
it('does not overlap a running attempt with a second walk', async () => {
operations.request.mockImplementation(async () => {
const row = await child('running', null, 'operations');
return row.id;
});
const scheduler = createScheduler();
await scheduler.tick();
await pump();
expect((await hourlyRow())?.state).toBe('running');
await scheduler.tick();
await pump();
const hourlyCount = await prisma.tbankSyncWork.count({
where: { userId: USER, scopeKey: 'hourly' },
});
expect(hourlyCount).toBe(1);
expect((await hourlyRow())?.state).toBe('running');
await jest.advanceTimersByTimeAsync(16 * 60 * 1000);
await pump(20);
expect((await hourlyRow())?.state).not.toBe('running');
});
it('does not start a new attempt before the next hourly term', async () => {
const scheduler = createScheduler();
const row = await runWalk(scheduler);
const firstAttemptId = row.attemptId;
expect(row.state).toBe('complete');
await scheduler.tick();
expect((await hourlyRow())?.attemptId).toBe(firstAttemptId);
await jest.advanceTimersByTimeAsync(59 * 60 * 1000);
await scheduler.tick();
expect((await hourlyRow())?.attemptId).toBe(firstAttemptId);
await jest.advanceTimersByTimeAsync(2 * 60 * 1000);
await scheduler.tick();
await pump(30);
expect((await hourlyRow())?.attemptId).not.toBe(firstAttemptId);
});
it('counts a late step result before the attempt outcome is recorded', async () => {
const late: { id: string }[] = [];
operations.request.mockImplementation(async () => {
const row = await child('running', null, 'operations');
late.push(row);
return row.id;
});
const scheduler = createScheduler();
await scheduler.tick();
await pump();
assert.ok(late.length > 0);
for (const row of late) {
await prisma.tbankSyncWork.update({
where: { id: row.id },
data: { state: 'complete' },
});
}
let settled = false;
for (let i = 0; i < 100 && !settled; i++) {
await immediate();
await jest.advanceTimersByTimeAsync(200);
const current = await hourlyRow();
settled = Boolean(current && current.state !== 'running');
}
const row = await hourlyRow();
expect(row).not.toBeNull();
expect(row?.state).not.toBe('running');
expect((row?.progress as HourlyProgressView).result).toBe('succeeded');
});
it('treats a step superseded by a successor as stale and keeps CAS fencing', async () => {
const stale = await child('complete', null, 'membership');
const attemptId = randomUUID();
const id = randomUUID();
await prisma.tbankSyncWork.create({
data: {
id,
userId: USER,
revision: REVISION,
scopeKey: 'hourly',
attemptId,
processRunId: 'dead-process',
priority: 0,
intent: {},
state: 'running',
progress: {
children: [{ id: stale.id, section: 'membership' }],
attempts: { [stale.id]: randomUUID() },
sections: [],
},
},
});
const foreign = await repository.settleHourly(
{ userId: USER, revision: REVISION, id, attemptId: randomUUID() },
'INTERRUPTED',
);
expect(foreign.count).toBe(0);
expect((await hourlyRow())?.state).toBe('running');
const closed = await repository.settleHourly(
{ userId: USER, revision: REVISION, id, attemptId },
'INTERRUPTED',
);
expect(closed.count).toBe(1);
const progress = (await hourlyRow())?.progress as HourlyProgressView;
expect(progress.result).toBe('failed');
expect(progress.sections[0].errorCode).toBeNull();
const late = await repository.settleHourly(
{ userId: USER, revision: REVISION, id, attemptId },
'INTERRUPTED',
);
expect(late.count).toBe(0);
});
it('creates no reservations after a controlled shutdown', async () => {
const scheduler = createScheduler();
scheduler.onModuleInit();
scheduler.onModuleDestroy();
await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000);
await pump(5);
expect(await prisma.tbankSyncWork.count({ where: { userId: USER } })).toBe(0);
});
it('closes the operations step durably when its finalizer cannot record', async () => {
await prisma.tbankConnection.update({
where: { userId: USER },
data: { membershipAt: new Date() },
});
await prisma.tbankAccount.create({
data: {
userId: USER,
revision: REVISION,
accountId: 'a',
type: 'BROKER',
name: 'Test',
upstreamOrder: 0,
},
});
const accessReal = new TbankAccessService(prisma, tokens);
const operationsRepository = new BrokerageOperationsRepository(prisma);
const sync = new BrokerageOperationsSyncService(
operationsRepository,
accessReal,
{ getOperationsByCursor: jest.fn() } as never,
logger as never,
);
jest
.spyOn(operationsRepository, 'pending')
.mockRejectedValueOnce(new Error('db'));
jest
.spyOn(operationsRepository, 'fail')
.mockRejectedValueOnce(new Error('db'));
const id = await sync.request(
USER,
[
{
accountId: 'a',
from: new Date(Date.UTC(2026, 0, 1)),
to: new Date(),
},
] as never,
true,
'screen',
REVISION,
);
assert.ok(id);
await pump(40);
const row = await prisma.tbankSyncWork.findFirst({
where: { userId: USER, id },
});
expect(row?.state).toBe('failed');
});
it('finishes open catalog steps durably when publication fails mid-batch', async () => {
const accessReal = new TbankAccessService(prisma, tokens);
const catalogRepository = new CatalogDiscoveryRepository(prisma);
const enrichment = new CatalogEnrichmentService(
catalogRepository,
accessReal,
{ getLastPrices: jest.fn().mockResolvedValue([]) } as never,
logger as never,
{} as never,
{} as never,
{} as never,
);
jest
.spyOn(catalogRepository, 'publishSource')
.mockRejectedValue(new Error('db'));
await enrichment.requestMany(USER, REVISION, 'share', ['uid'], false, [
'last-price',
]);
await pump(40);
const rows = await prisma.tbankSyncWork.findMany({
where: { userId: USER, scopeKey: { not: 'hourly' } },
});
assert.ok(rows.length > 0);
for (const row of rows) expect(row.state).toBe('failed');
});
});