194 lines
6.2 KiB
TypeScript
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')
|
|
}
|