refactor: share tbank-sync claim and generation lock (WealthLedger/backend#166)

This commit is contained in:
2026-11-04 18:44:01 +03:00
parent c5ccfeadea
commit c7bf09e2a1
8 changed files with 171 additions and 206 deletions
@@ -12,6 +12,7 @@ import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
import { TbankErrorCode } from './tbank-error-code';
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0;
@@ -119,11 +120,8 @@ export class InstrumentMetadataRepository {
active: ReadonlySet<string>,
) {
return this.prisma.$transaction(async (tx) => {
const valid = await tx.tbankConnection.updateMany({
where: { ...attemptOwner, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!valid.count) return null;
const valid = await lockConnectionGeneration(tx, attemptOwner);
if (!valid) return null;
const scope = {
...attemptOwner,
scopeKey: `metadata:${metadataKey(identity)}`,
@@ -167,15 +165,11 @@ export class InstrumentMetadataRepository {
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', progress: {} },
const claimed = await claimSyncWork(tx, attempt, {
state: 'complete',
progress: {},
});
if (!claimed.count) return false;
if (!claimed) return false;
const aliases = new Map(
results.map((result) => [`${result.type}:${result.id}`, result]),
);
@@ -264,18 +258,17 @@ export class InstrumentMetadataRepository {
}
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
...(success ? { connection: { invalidToken: false } } : {}),
},
data: {
await claimSyncWork(
this.prisma,
attempt,
{
state: success ? 'complete' : 'failed',
errorCode: success ? null : TbankErrorCode.Unavailable,
},
});
{ withoutConnectionFence: !success },
);
if (success) {
// Fails sibling attempts whose connection was invalidated mid-flight.
await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
+14 -27
View File
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
import { TbankTokenService } from './tbank-token.service';
const EXPECTED_UPDATED_CONNECTIONS = 1;
@@ -56,11 +57,8 @@ export class TbankAccessService {
force: boolean,
) {
return this.prisma.$transaction(async (tx) => {
const current = await tx.tbankConnection.updateMany({
where: { userId, revision, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const current = await lockConnectionGeneration(tx, { userId, revision });
if (!current) return null;
const connection = await tx.tbankConnection.findUniqueOrThrow({
where: { userId },
});
@@ -110,15 +108,11 @@ export class TbankAccessService {
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
if (invalidToken) return this.rejectMembershipToken(attempt);
const result = await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
const result = await claimSyncWork(this.prisma, attempt, {
state: 'failed',
errorCode: TbankErrorCode.Unavailable,
});
return result.count === EXPECTED_UPDATED_CONNECTIONS;
return result === EXPECTED_UPDATED_CONNECTIONS;
}
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
@@ -163,15 +157,8 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below.
const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { updatedAt: new Date() },
});
if (!claimed.count) return false;
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
if (!claimed) return false;
const { userId, revision } = attempt;
const previous = await tx.tbankAccount.findMany({
where: { userId, revision },
@@ -244,11 +231,11 @@ export class TbankAccessService {
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
if (!claimed.count) return false;
const claimed = await claimSyncWork(tx, attempt, {
state: 'failed',
errorCode: TbankErrorCode.InvalidToken,
}, { withoutConnectionFence: true });
if (!claimed) return false;
await tx.tbankConnection.update({
where: {
userId_revision: {
@@ -0,0 +1,57 @@
import type { Prisma } from '../../../generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
const NO_UPDATED_ROWS = 0;
// Shared owners for the tbank-sync DB claim/lock protocol (backend#166).
// Both helpers are CAS fences: they run inside the caller's transaction and
// keep the exact where/data shapes they replaced. Callers keep deciding
// success from the claim count exactly as before.
// Claims the active TbankSyncWork row: only the still-running attempt of a
// valid connection may transition. `data` is the state transition to apply.
// `state` overrides the default 'running' fence (e.g. an initiator row also
// accepts 'complete'); `withoutConnectionFence` drops the
// `connection: { invalidToken: false }` guard for fences that must also fire
// on an invalidated connection.
export type ClaimOptions = {
// Overrides the default 'running' fence (e.g. an initiator row also
// accepts 'complete').
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
// true drops the `connection: { invalidToken: false }` guard for fences
// that must also fire on an invalidated connection.
withoutConnectionFence: boolean | undefined;
};
export async function claimSyncWork(
tx: Prisma.TransactionClient,
attempt: TbankSyncAttempt,
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
options: Partial<ClaimOptions> = {},
): Promise<number> {
const result = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: options.state ?? 'running',
...(options.withoutConnectionFence
? {}
: { connection: { invalidToken: false } }),
},
data,
});
return result.count;
}
// Locks the connection generation before reserving or publishing work: the
// row must still be the current revision with a valid token. `extra`
// (e.g. account membership) is merged into the where clause.
export async function lockConnectionGeneration(
tx: Prisma.TransactionClient,
owner: { revision: string; userId: string },
extra: Prisma.TbankConnectionWhereInput = {},
): Promise<boolean> {
const result = await tx.tbankConnection.updateMany({
where: { ...owner, ...extra, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
return result.count > NO_UPDATED_ROWS;
}