mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
131 lines
4.4 KiB
TypeScript
131 lines
4.4 KiB
TypeScript
import type { CompletionReceiptJournal } from '../ports/completionReceiptJournal';
|
|
import type { CompletionReceiptStore } from '../ports/completionReceiptStore';
|
|
import type { PrimaryRunRecoveryCursor } from '../ports/primaryRunRecoverySource';
|
|
import { isTerminalRunAttemptStatus } from '../domain/runStateMachine';
|
|
import type { PrimaryCompletionReceiptConsumer } from './primaryCompletionReceiptConsumer';
|
|
import type { PrimaryCompletionReceiptScanSummary } from './primaryCompletionReceiptScanner';
|
|
|
|
export interface PrimaryCompletionReceiptJournalScannerOptions {
|
|
terminalMissingRetentionMs?: number;
|
|
clock?: { now(): number };
|
|
}
|
|
|
|
/**
|
|
* Database-indexed receipt retention. The supervisor cursor is only a transport
|
|
* shape here: createdAtMs carries journal.updatedAtMs and runId carries Attempt
|
|
* id. No directory enumeration is performed.
|
|
*/
|
|
export class PrimaryCompletionReceiptJournalScanner {
|
|
private readonly terminalMissingRetentionMs: number;
|
|
private readonly clock: { now(): number };
|
|
|
|
constructor(
|
|
private readonly journal: CompletionReceiptJournal,
|
|
private readonly store: CompletionReceiptStore,
|
|
private readonly consumer: Pick<
|
|
PrimaryCompletionReceiptConsumer,
|
|
'consume'
|
|
>,
|
|
options: PrimaryCompletionReceiptJournalScannerOptions = {},
|
|
) {
|
|
this.terminalMissingRetentionMs =
|
|
options.terminalMissingRetentionMs ?? 60_000;
|
|
this.clock = options.clock ?? { now: Date.now };
|
|
if (
|
|
!Number.isSafeInteger(this.terminalMissingRetentionMs) ||
|
|
this.terminalMissingRetentionMs < 0 ||
|
|
this.terminalMissingRetentionMs > 24 * 60 * 60_000
|
|
) {
|
|
throw new RangeError(
|
|
'terminalMissingRetentionMs must be between 0 and 24 hours',
|
|
);
|
|
}
|
|
}
|
|
|
|
async scanBatch(
|
|
options: { cursor?: PrimaryRunRecoveryCursor; limit?: number } = {},
|
|
): Promise<PrimaryCompletionReceiptScanSummary> {
|
|
const observedAtMs = this.clock.now();
|
|
if (!Number.isSafeInteger(observedAtMs) || observedAtMs < 0) {
|
|
throw new RangeError('Completion receipt observation time is invalid');
|
|
}
|
|
const page = await this.journal.listCandidates({
|
|
observedAtMs,
|
|
...(options.limit === undefined ? {} : { limit: options.limit }),
|
|
...(options.cursor === undefined
|
|
? {}
|
|
: {
|
|
cursor: {
|
|
updatedAtMs: options.cursor.createdAtMs,
|
|
attemptId: options.cursor.runId,
|
|
},
|
|
}),
|
|
});
|
|
const summary: PrimaryCompletionReceiptScanSummary = {
|
|
scanned: page.candidates.length,
|
|
applied: 0,
|
|
alreadyTerminal: 0,
|
|
quarantined: 0,
|
|
purgedQuarantines: 0,
|
|
expiredMissing: 0,
|
|
missing: 0,
|
|
cleanupPending: 0,
|
|
skipped: 0,
|
|
ambiguous: 0,
|
|
failed: 0,
|
|
truncated: page.truncated,
|
|
unsafeAttemptOverflow: false,
|
|
...(page.nextCursor === undefined
|
|
? {}
|
|
: {
|
|
nextCursor: {
|
|
createdAtMs: page.nextCursor.updatedAtMs,
|
|
runId: page.nextCursor.attemptId,
|
|
},
|
|
}),
|
|
};
|
|
|
|
for (const candidate of page.candidates) {
|
|
if (candidate.executorType !== 'local_process') {
|
|
summary.skipped += 1;
|
|
continue;
|
|
}
|
|
try {
|
|
if (candidate.state === 'quarantined') {
|
|
await this.store.quarantine(candidate.attemptId);
|
|
await this.store.purgeQuarantine(candidate.attemptId);
|
|
await this.journal.resolve(candidate.attemptId);
|
|
summary.purgedQuarantines += 1;
|
|
continue;
|
|
}
|
|
|
|
const result = await this.consumer.consume(candidate.attemptId);
|
|
if (result.status === 'quarantined') {
|
|
summary.quarantined += 1;
|
|
continue;
|
|
}
|
|
if (result.status === 'missing') {
|
|
if (
|
|
isTerminalRunAttemptStatus(candidate.attemptStatus) &&
|
|
candidate.finishedAtMs !== undefined &&
|
|
candidate.finishedAtMs + this.terminalMissingRetentionMs <=
|
|
observedAtMs
|
|
) {
|
|
await this.journal.resolve(candidate.attemptId);
|
|
summary.expiredMissing += 1;
|
|
} else {
|
|
summary.missing += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (result.status === 'applied') summary.applied += 1;
|
|
else summary.alreadyTerminal += 1;
|
|
if (!result.cleaned) summary.cleanupPending += 1;
|
|
} catch {
|
|
summary.failed += 1;
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
}
|