Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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')
|
||||
}
|
||||
Reference in New Issue
Block a user