Files
geointel/frontend/src/services/segmentationJob.ts
T

194 lines
6.2 KiB
TypeScript

import type { JobRead, SegmentationRunRead, SegmentationRunRequest, SegmentationRunResponse } from '../types'
import { jobsApi } from './api/jobs'
const ACTIVE_JOB_STATUSES = new Set(['queued', 'running'])
const TERMINAL_FAILURE_STATUSES = new Set(['failed', 'cancelled', 'partial'])
export const SEGMENTATION_JOB_POLL_INTERVAL_MS = 1_500
export const SEGMENTATION_JOB_TIMEOUT_MS = 30 * 60 * 1_000
export class SegmentationJobError extends Error {
readonly code: string
readonly jobId: string
constructor(message: string, code: string, jobId: string) {
super(message)
this.name = 'SegmentationJobError'
this.code = code
this.jobId = jobId
}
}
interface WaitForSegmentationJobOptions {
projectId: string
initialJob: JobRead
signal?: AbortSignal
intervalMs?: number
timeoutMs?: number
maxConsecutiveReadErrors?: number
readJob?: (projectId: string, jobId: string) => Promise<JobRead>
onStatus?: (job: JobRead) => void
}
function abortedError(): Error {
const error = new Error('Het volgen van de segmentatietaak is gestopt')
error.name = 'AbortError'
return error
}
function wait(milliseconds: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return Promise.reject(abortedError())
}
if (milliseconds <= 0) {
return Promise.resolve()
}
return new Promise((resolve, reject) => {
const timer = window.setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, milliseconds)
const onAbort = () => {
window.clearTimeout(timer)
signal?.removeEventListener('abort', onAbort)
reject(abortedError())
}
signal?.addEventListener('abort', onAbort, { once: true })
})
}
function stringValue(record: Record<string, unknown> | null | undefined, key: string): string | null {
const value = record?.[key]
return typeof value === 'string' && value.trim() ? value.trim() : null
}
function numberValue(record: Record<string, unknown> | null | undefined, key: string): number | null {
const value = record?.[key]
return typeof value === 'number' && Number.isFinite(value) ? value : null
}
function assertSegmentationJobIdentity(projectId: string, job: JobRead): void {
if (job.project_id !== projectId || job.job_type !== 'segmentation.run') {
throw new SegmentationJobError(
'De server koppelde een onverwachte taak aan deze segmentatie',
'SEGMENTATION_JOB_IDENTITY_MISMATCH',
job.id,
)
}
}
/** Follow one queued GPU segmentation until the backend marks it terminal. */
export async function waitForSegmentationJob({
projectId,
initialJob,
signal,
intervalMs = SEGMENTATION_JOB_POLL_INTERVAL_MS,
timeoutMs = SEGMENTATION_JOB_TIMEOUT_MS,
maxConsecutiveReadErrors = 3,
readJob = jobsApi.get,
onStatus,
}: WaitForSegmentationJobOptions): Promise<JobRead> {
const startedAt = Date.now()
let job = initialJob
let consecutiveReadErrors = 0
while (true) {
if (signal?.aborted) {
throw abortedError()
}
assertSegmentationJobIdentity(projectId, job)
onStatus?.(job)
if (job.status === 'success') {
return job
}
if (TERMINAL_FAILURE_STATUSES.has(job.status)) {
const code = stringValue(job.result_json, 'error_code') ?? `SEGMENTATION_JOB_${job.status.toUpperCase()}`
const message = job.error_message
?? stringValue(job.result_json, 'message')
?? 'De GPU-taak is niet volledig uitgevoerd'
throw new SegmentationJobError(message, code, job.id)
}
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
throw new SegmentationJobError(
`De segmentatietaak heeft een onbekende status: ${job.status}`,
'SEGMENTATION_JOB_STATUS_INVALID',
job.id,
)
}
if (Date.now() - startedAt >= timeoutMs) {
throw new SegmentationJobError(
'De segmentatietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde segmentatieruns om het resultaat later te bekijken.',
'SEGMENTATION_JOB_POLL_TIMEOUT',
job.id,
)
}
await wait(intervalMs, signal)
try {
job = await readJob(projectId, job.id)
consecutiveReadErrors = 0
} catch (error) {
if (signal?.aborted) {
throw abortedError()
}
consecutiveReadErrors += 1
if (consecutiveReadErrors >= maxConsecutiveReadErrors) {
throw error
}
}
}
}
/** Convert persisted server evidence into the UI summary contract. */
export function completedSegmentationResponse(
request: SegmentationRunRequest,
job: JobRead,
run: SegmentationRunRead,
): SegmentationRunResponse {
assertSegmentationJobIdentity(request.project_id, job)
if (
job.status !== 'success'
|| run.status !== 'success'
|| run.analysis_type !== 'segmentation'
|| run.project_id !== request.project_id
|| run.dataset_id !== request.dataset_id
|| run.job_id !== job.id
|| (run.model_name != null && run.model_name !== request.model_id)
) {
throw new SegmentationJobError(
'De bewaarde segmentatierun komt niet overeen met de voltooide GPU-taak',
'SEGMENTATION_RUN_RESULT_MISMATCH',
job.id,
)
}
const segmentationCount = numberValue(job.result_json, 'segmentation_count')
?? numberValue(run.result_json, 'segmentation_count')
if (segmentationCount === null || !Number.isInteger(segmentationCount) || segmentationCount < 0) {
throw new SegmentationJobError(
'De voltooide segmentatietaak bevat geen geldige, herleidbare vlakkentelling',
'SEGMENTATION_RUN_RESULT_INCOMPLETE',
job.id,
)
}
return {
analysis_run_id: run.id,
job_id: job.id,
project_id: request.project_id,
dataset_id: request.dataset_id,
model_id: run.model_name ?? request.model_id,
status: 'success',
segmentation_count: segmentationCount,
error_code: null,
message: segmentationCount === 0
? 'Segmentatie voltooid zonder vlakken boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied geen relevante objecten bevat.'
: 'GPU-segmentatie voltooid; de bewaarde vlakken zijn geladen.',
}
}
export function analysisRunIdFromSegmentationJob(job: JobRead): string | null {
return stringValue(job.result_json, 'analysis_run_id')
}