91 lines
4.9 KiB
TypeScript
91 lines
4.9 KiB
TypeScript
import { createHash } from 'node:crypto';
|
|
import type { ArtifactDeletionRequest, AssessorRepository } from '../../src/persistence/assessor-repository';
|
|
import { RepositoryConflictError, RepositoryNotFoundError, RepositoryValidationError } from '../../src/persistence/repository-errors';
|
|
import { createGpoReprocessingJob } from './gpo-backup-submission-service';
|
|
import type { BackendArtifactStore } from '../artifacts/backend-artifact-store';
|
|
import { requireGpoSource } from './gpo-source-availability';
|
|
|
|
function stableDeletionId(storageKey: string): string {
|
|
return `artifact-delete-${createHash('sha256').update(storageKey).digest('hex').slice(0, 32)}`;
|
|
}
|
|
|
|
export class GpoBackupManagementService {
|
|
constructor(
|
|
private readonly repository: AssessorRepository,
|
|
private readonly artifactStore: BackendArtifactStore,
|
|
private readonly now: () => Date = () => new Date()
|
|
) {}
|
|
|
|
async retry(projectId: string, artifactId: string) {
|
|
const artifact = (await this.repository.listImportArtifacts(projectId)).find(item => item.id === artifactId);
|
|
if (!artifact) throw new RepositoryNotFoundError('The GPO artifact is not present in this project.');
|
|
await requireGpoSource(artifact, this.artifactStore);
|
|
const jobs = (await this.repository.listIngestionJobs(projectId))
|
|
.filter(item => item.artifactId === artifactId && item.kind === 'analyze-gpo-backup')
|
|
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt) || right.id.localeCompare(left.id));
|
|
if (!jobs[0]) throw new RepositoryNotFoundError('Der GPO-Analysejob ist im Mandanten nicht vorhanden.');
|
|
const job = await this.repository.retryIngestionJob(projectId, jobs[0].id, this.now().toISOString());
|
|
return { version: 1 as const, artifactId, jobId: job.id, status: job.status, attempts: job.attempts };
|
|
}
|
|
|
|
async reprocess(projectId: string, artifactId: string, expectedLatestProcessingRunId: string) {
|
|
if (!expectedLatestProcessingRunId.trim()) throw new RepositoryValidationError('Der erwartete letzte Processing Run ist erforderlich.');
|
|
const artifact = (await this.repository.listImportArtifacts(projectId)).find(item => item.id === artifactId);
|
|
if (!artifact || artifact.storage.kind !== 'backend-controlled') throw new RepositoryNotFoundError('Das GPO-Artefakt ist im Mandanten nicht vorhanden.');
|
|
const runs = (await this.repository.listProcessingRuns(artifactId))
|
|
.filter(item => item.projectId === projectId)
|
|
.sort((left, right) => Date.parse(right.completedAt) - Date.parse(left.completedAt) || right.id.localeCompare(left.id));
|
|
const latest = runs[0];
|
|
if (!latest) throw new RepositoryNotFoundError('Für das GPO-Artefakt ist noch kein Processing Run vorhanden.');
|
|
if (latest.id !== expectedLatestProcessingRunId) {
|
|
throw new RepositoryConflictError('Der erwartete letzte Processing Run ist nicht mehr aktuell.');
|
|
}
|
|
await requireGpoSource(artifact, this.artifactStore);
|
|
const job = await this.repository.enqueueIngestionJob(createGpoReprocessingJob(artifact, latest.id, this.now().toISOString()));
|
|
return {
|
|
version: 1 as const,
|
|
artifactId,
|
|
jobId: job.id,
|
|
status: job.status,
|
|
supersedesProcessingRunId: latest.id
|
|
};
|
|
}
|
|
async delete(projectId: string, artifactId: string) {
|
|
const artifact = (await this.repository.listImportArtifacts(projectId)).find(item => item.id === artifactId);
|
|
if (!artifact || artifact.storage.kind !== 'backend-controlled') throw new RepositoryNotFoundError('Das GPO-Artefakt ist im Mandanten nicht vorhanden.');
|
|
const timestamp = this.now().toISOString();
|
|
const request: ArtifactDeletionRequest = {
|
|
version: 1,
|
|
id: stableDeletionId(artifact.storage.storageKey),
|
|
projectId,
|
|
artifactId,
|
|
storageKey: artifact.storage.storageKey,
|
|
attempts: 0,
|
|
nextAttemptAt: timestamp,
|
|
createdAt: timestamp
|
|
};
|
|
const deleted = await this.repository.deleteGpoArtifact(projectId, artifactId, request);
|
|
const storageRemoval = await processArtifactDeletionRequest(this.repository, this.artifactStore, deleted.request, this.now);
|
|
return { version: 1 as const, artifactId, deleted: true as const, storageRemoval };
|
|
}
|
|
}
|
|
|
|
export async function processArtifactDeletionRequest(
|
|
repository: AssessorRepository,
|
|
artifactStore: BackendArtifactStore,
|
|
request: ArtifactDeletionRequest,
|
|
now: () => Date = () => new Date()
|
|
): Promise<'complete' | 'pending'> {
|
|
try {
|
|
await artifactStore.remove(request.storageKey);
|
|
await repository.completeArtifactDeletionRequest(request.id);
|
|
return 'complete';
|
|
} catch {
|
|
const failedAt = now();
|
|
const retryAt = new Date(failedAt.getTime() + Math.min(60 * 60 * 1_000, 5_000 * 2 ** Math.min(request.attempts, 8)));
|
|
await repository.failArtifactDeletionRequest(request.id, failedAt.toISOString(), 'artifact-storage-delete-failed', retryAt.toISOString())
|
|
.catch(() => undefined);
|
|
return 'pending';
|
|
}
|
|
}
|