refactor: share tbank-sync claim and generation lock (WealthLedger/backend#166)
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user