fix: restore terminal hourly lifecycle after persistence failure (#56)
This commit is contained in:
@@ -18,7 +18,19 @@ export type HourlySection = {
|
||||
errorCode: null | string;
|
||||
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 = {
|
||||
attempts?: Record<string, string>;
|
||||
children?: HourlyChild[];
|
||||
result?: 'failed' | 'partial' | 'succeeded';
|
||||
sections: HourlySection[];
|
||||
};
|
||||
@@ -191,12 +203,10 @@ export class TbankSyncRepository {
|
||||
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
|
||||
if (previous.state === 'running') {
|
||||
if (previous.processRunId === runId) return null;
|
||||
// A dead process cannot finish its attempt; interrupt it, but its
|
||||
// start stays the basis of the next term.
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { userId_revision_scopeKey: key },
|
||||
data: { state: 'cancelled', errorCode: null },
|
||||
});
|
||||
// A dead process cannot finish its attempt; close it from its
|
||||
// actual step results, but its start stays the basis of the next
|
||||
// term.
|
||||
await this.closeHourly(tx, previous, 'INTERRUPTED', []);
|
||||
if (!due) return null;
|
||||
} else if (!due) return null;
|
||||
}
|
||||
@@ -220,28 +230,146 @@ export class TbankSyncRepository {
|
||||
});
|
||||
}
|
||||
|
||||
settleHourly(
|
||||
attempt: TbankSyncAttempt,
|
||||
result: 'failed' | 'partial' | 'succeeded',
|
||||
private classifyStep(
|
||||
child: HourlyChild,
|
||||
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[],
|
||||
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({
|
||||
where: {
|
||||
id: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
userId: attempt.userId,
|
||||
revision: attempt.revision,
|
||||
state: 'running',
|
||||
},
|
||||
const progress = work.progress as HourlyProgress;
|
||||
const children = progress.children ?? [];
|
||||
const rows = children.length
|
||||
? await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId: work.userId,
|
||||
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: {
|
||||
state: result === 'failed' ? 'failed' : 'complete',
|
||||
errorCode:
|
||||
sections.find((section) => section.errorCode)?.errorCode ?? null,
|
||||
progress: { result, sections },
|
||||
cause ??
|
||||
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) {
|
||||
const connection = await this.prisma.tbankConnection.findFirst({
|
||||
where: { userId },
|
||||
|
||||
Reference in New Issue
Block a user