Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DetectionRunRequest } from '../../types'
|
||||
import { detectionApi } from './detection'
|
||||
|
||||
describe('detectionApi.runAsync', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('starts production inference only through the queued endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
data: {
|
||||
id: 'job-1',
|
||||
job_type: 'detection.run',
|
||||
status: 'queued',
|
||||
project_id: 'project 1',
|
||||
parameters_json: {},
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const payload: DetectionRunRequest = {
|
||||
project_id: 'project 1',
|
||||
dataset_id: 'dataset-1',
|
||||
model_id: 'yolo-configured',
|
||||
confidence_threshold: 0.15,
|
||||
tile_manifest_path: '/tiles/manifest.json',
|
||||
}
|
||||
|
||||
const response = await detectionApi.runAsync(payload)
|
||||
|
||||
expect(response.status).toBe('queued')
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/v1/detection/run-async?project_id=project%201')
|
||||
expect(init).toMatchObject({ method: 'POST', credentials: 'same-origin' })
|
||||
expect(JSON.parse(String(init.body))).toEqual(payload)
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
DetectionRunListResponse,
|
||||
DetectionRunRead,
|
||||
DetectionRunRequest,
|
||||
DetectionRunResponse,
|
||||
JobRead,
|
||||
ModelAssetListResponse,
|
||||
YoloPreflightResponse,
|
||||
} from '../../types'
|
||||
@@ -28,12 +28,12 @@ export const detectionApi = {
|
||||
listModelAssets: (): Promise<ModelAssetListResponse> => apiGet<ModelAssetListResponse>('/api/v1/detection/model-assets'),
|
||||
getYoloPreflight: (params: { tile_manifest_path?: string | null; check_model_load?: boolean | null; model_asset_id?: string | null } = {}): Promise<YoloPreflightResponse> =>
|
||||
apiGet<YoloPreflightResponse>(`/api/v1/detection/yolo/preflight${queryString(params)}`),
|
||||
run: (payload: DetectionRunRequest): Promise<DetectionRunResponse> =>
|
||||
apiPost<DetectionRunResponse>(`/api/v1/detection/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
|
||||
runAsync: (payload: DetectionRunRequest): Promise<JobRead> =>
|
||||
apiPost<JobRead>(`/api/v1/detection/run-async?project_id=${encodeURIComponent(payload.project_id)}`, payload),
|
||||
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<DetectionRunListResponse> =>
|
||||
apiGet<DetectionRunListResponse>(`/api/v1/detection/runs${queryString(params)}`),
|
||||
getRun: (analysisRunId: string): Promise<DetectionRunRead> =>
|
||||
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}`),
|
||||
getRun: (analysisRunId: string, projectId?: string | null): Promise<DetectionRunRead> =>
|
||||
apiGet<DetectionRunRead>(`/api/v1/detection/runs/${analysisRunId}${queryString({ project_id: projectId })}`),
|
||||
listDetections: (
|
||||
analysisRunId: string,
|
||||
params: {
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { SegmentationRunRequest } from '../../types'
|
||||
import { segmentationApi } from './segmentation'
|
||||
|
||||
describe('segmentationApi.runAsync', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('starts production segmentation only through the queued endpoint', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
data: {
|
||||
id: 'job-1',
|
||||
job_type: 'segmentation.run',
|
||||
status: 'queued',
|
||||
project_id: 'project 1',
|
||||
parameters_json: {},
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
const payload: SegmentationRunRequest = {
|
||||
project_id: 'project 1',
|
||||
dataset_id: 'dataset-1',
|
||||
model_id: 'yolo-seg-configured',
|
||||
confidence_threshold: 0.5,
|
||||
tile_manifest_path: '/tiles/manifest.json',
|
||||
}
|
||||
|
||||
const response = await segmentationApi.runAsync(payload)
|
||||
|
||||
expect(response.status).toBe('queued')
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
const [url, init] = fetchMock.mock.calls[0]
|
||||
expect(url).toBe('/api/v1/segmentation/run-async?project_id=project%201')
|
||||
expect(init).toMatchObject({ method: 'POST', credentials: 'same-origin' })
|
||||
expect(JSON.parse(String(init.body))).toEqual(payload)
|
||||
})
|
||||
|
||||
it('scopes a persisted run read to the active guest project', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
data: {
|
||||
id: 'run-1',
|
||||
analysis_type: 'segmentation',
|
||||
status: 'success',
|
||||
project_id: 'project 1',
|
||||
parameters_json: {},
|
||||
},
|
||||
}), { status: 200, headers: { 'Content-Type': 'application/json' } }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
await segmentationApi.getRun('run-1', 'project 1')
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledOnce()
|
||||
expect(fetchMock.mock.calls[0][0]).toBe('/api/v1/segmentation/runs/run-1?project_id=project+1')
|
||||
})
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import type {
|
||||
SegmentationRunListResponse,
|
||||
SegmentationRunRead,
|
||||
SegmentationRunRequest,
|
||||
SegmentationRunResponse,
|
||||
JobRead,
|
||||
} from '../../types'
|
||||
|
||||
function queryString(params: Record<string, string | number | null | undefined>): string {
|
||||
@@ -23,12 +23,12 @@ function queryString(params: Record<string, string | number | null | undefined>)
|
||||
|
||||
export const segmentationApi = {
|
||||
listModels: (): Promise<SegmentationModelsResponse> => apiGet<SegmentationModelsResponse>('/api/v1/segmentation/models'),
|
||||
run: (payload: SegmentationRunRequest): Promise<SegmentationRunResponse> =>
|
||||
apiPost<SegmentationRunResponse>(`/api/v1/segmentation/run?project_id=${encodeURIComponent(payload.project_id)}`, payload),
|
||||
runAsync: (payload: SegmentationRunRequest): Promise<JobRead> =>
|
||||
apiPost<JobRead>(`/api/v1/segmentation/run-async?project_id=${encodeURIComponent(payload.project_id)}`, payload),
|
||||
listRuns: (params: { project_id?: string | null; dataset_id?: string | null } = {}): Promise<SegmentationRunListResponse> =>
|
||||
apiGet<SegmentationRunListResponse>(`/api/v1/segmentation/runs${queryString(params)}`),
|
||||
getRun: (analysisRunId: string): Promise<SegmentationRunRead> =>
|
||||
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}`),
|
||||
getRun: (analysisRunId: string, projectId?: string | null): Promise<SegmentationRunRead> =>
|
||||
apiGet<SegmentationRunRead>(`/api/v1/segmentation/runs/${analysisRunId}${queryString({ project_id: projectId })}`),
|
||||
listSegmentations: (
|
||||
analysisRunId: string,
|
||||
params: { project_id: string; dataset_id?: string | null; class_name?: string | null; min_confidence?: number | null },
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { DetectionRunRead, DetectionRunRequest, JobRead } from '../types'
|
||||
import {
|
||||
completedDetectionResponse,
|
||||
DetectionJobError,
|
||||
waitForDetectionJob,
|
||||
} from './detectionJob'
|
||||
|
||||
const projectId = 'project-1'
|
||||
const datasetId = 'dataset-1'
|
||||
const jobId = 'job-1'
|
||||
|
||||
function job(status: string, overrides: Partial<JobRead> = {}): JobRead {
|
||||
return {
|
||||
id: jobId,
|
||||
job_type: 'detection.run',
|
||||
status,
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
parameters_json: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function run(overrides: Partial<DetectionRunRead> = {}): DetectionRunRead {
|
||||
return {
|
||||
id: 'run-1',
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
job_id: jobId,
|
||||
analysis_type: 'detection',
|
||||
status: 'success',
|
||||
model_name: 'yolo-configured',
|
||||
parameters_json: {},
|
||||
result_json: { detection_count: 4 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const request: DetectionRunRequest = {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: 'yolo-configured',
|
||||
confidence_threshold: 0.15,
|
||||
tile_manifest_path: '/tiles/manifest.json',
|
||||
}
|
||||
|
||||
describe('waitForDetectionJob', () => {
|
||||
it('follows queued and running states until the persisted GPU job succeeds', async () => {
|
||||
const readJob = vi.fn()
|
||||
.mockResolvedValueOnce(job('running'))
|
||||
.mockResolvedValueOnce(job('success', { result_json: { detection_count: 4 } }))
|
||||
const statuses: string[] = []
|
||||
|
||||
const completed = await waitForDetectionJob({
|
||||
projectId,
|
||||
initialJob: job('queued'),
|
||||
intervalMs: 0,
|
||||
readJob,
|
||||
onStatus: (value) => statuses.push(value.status),
|
||||
})
|
||||
|
||||
expect(completed.status).toBe('success')
|
||||
expect(statuses).toEqual(['queued', 'running', 'success'])
|
||||
expect(readJob).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not reinterpret a failed model/runtime job as an empty success', async () => {
|
||||
await expect(waitForDetectionJob({
|
||||
projectId,
|
||||
initialJob: job('failed', {
|
||||
error_message: 'NVIDIA CUDA is niet beschikbaar',
|
||||
result_json: { error_code: 'DETECTION_ACCELERATOR_UNAVAILABLE' },
|
||||
}),
|
||||
intervalMs: 0,
|
||||
})).rejects.toMatchObject({
|
||||
name: 'DetectionJobError',
|
||||
code: 'DETECTION_ACCELERATOR_UNAVAILABLE',
|
||||
message: 'NVIDIA CUDA is niet beschikbaar',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects partial and cross-project jobs instead of treating them as complete', async () => {
|
||||
await expect(waitForDetectionJob({
|
||||
projectId,
|
||||
initialJob: job('partial'),
|
||||
intervalMs: 0,
|
||||
})).rejects.toBeInstanceOf(DetectionJobError)
|
||||
|
||||
await expect(waitForDetectionJob({
|
||||
projectId,
|
||||
initialJob: job('success', { project_id: 'other-project' }),
|
||||
intervalMs: 0,
|
||||
})).rejects.toMatchObject({ code: 'DETECTION_JOB_IDENTITY_MISMATCH' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('completedDetectionResponse', () => {
|
||||
it('uses the persisted count and explicitly avoids claiming that a zero result means absence', () => {
|
||||
const response = completedDetectionResponse(
|
||||
request,
|
||||
job('success', { result_json: { detection_count: 0 } }),
|
||||
run({ result_json: { detection_count: 0 } }),
|
||||
)
|
||||
|
||||
expect(response.detection_count).toBe(0)
|
||||
expect(response.status).toBe('success')
|
||||
expect(response.message).toContain('bewijst niet')
|
||||
})
|
||||
|
||||
it('fails closed when the server omits the persisted count or links another run', () => {
|
||||
expect(() => completedDetectionResponse(
|
||||
request,
|
||||
job('success'),
|
||||
run({ result_json: null }),
|
||||
)).toThrowError(DetectionJobError)
|
||||
|
||||
expect(() => completedDetectionResponse(
|
||||
request,
|
||||
job('success', { result_json: { detection_count: 2 } }),
|
||||
run({ job_id: 'another-job' }),
|
||||
)).toThrowError(DetectionJobError)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,197 @@
|
||||
import type { DetectionRunRead, DetectionRunRequest, DetectionRunResponse, JobRead } 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 DETECTION_JOB_POLL_INTERVAL_MS = 1_500
|
||||
export const DETECTION_JOB_TIMEOUT_MS = 30 * 60 * 1_000
|
||||
|
||||
export class DetectionJobError extends Error {
|
||||
readonly code: string
|
||||
readonly jobId: string
|
||||
|
||||
constructor(message: string, code: string, jobId: string) {
|
||||
super(message)
|
||||
this.name = 'DetectionJobError'
|
||||
this.code = code
|
||||
this.jobId = jobId
|
||||
}
|
||||
}
|
||||
|
||||
interface WaitForDetectionJobOptions {
|
||||
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 detectietaak 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 assertDetectionJobIdentity(projectId: string, job: JobRead): void {
|
||||
if (job.project_id !== projectId || job.job_type !== 'detection.run') {
|
||||
throw new DetectionJobError(
|
||||
'De server koppelde een onverwachte taak aan deze beeldanalyse',
|
||||
'DETECTION_JOB_IDENTITY_MISMATCH',
|
||||
job.id,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Follow one queued GPU run until the backend marks it terminal.
|
||||
*
|
||||
* A transient polling failure is retried, but an unknown or partial terminal
|
||||
* state is never interpreted as a completed inference. The backend remains
|
||||
* the only authority for the outcome and persisted detection count.
|
||||
*/
|
||||
export async function waitForDetectionJob({
|
||||
projectId,
|
||||
initialJob,
|
||||
signal,
|
||||
intervalMs = DETECTION_JOB_POLL_INTERVAL_MS,
|
||||
timeoutMs = DETECTION_JOB_TIMEOUT_MS,
|
||||
maxConsecutiveReadErrors = 3,
|
||||
readJob = jobsApi.get,
|
||||
onStatus,
|
||||
}: WaitForDetectionJobOptions): Promise<JobRead> {
|
||||
const startedAt = Date.now()
|
||||
let job = initialJob
|
||||
let consecutiveReadErrors = 0
|
||||
|
||||
while (true) {
|
||||
if (signal?.aborted) {
|
||||
throw abortedError()
|
||||
}
|
||||
assertDetectionJobIdentity(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') ?? `DETECTION_JOB_${job.status.toUpperCase()}`
|
||||
const message = job.error_message
|
||||
?? stringValue(job.result_json, 'message')
|
||||
?? 'De GPU-taak is niet volledig uitgevoerd'
|
||||
throw new DetectionJobError(message, code, job.id)
|
||||
}
|
||||
if (!ACTIVE_JOB_STATUSES.has(job.status)) {
|
||||
throw new DetectionJobError(
|
||||
`De detectietaak heeft een onbekende status: ${job.status}`,
|
||||
'DETECTION_JOB_STATUS_INVALID',
|
||||
job.id,
|
||||
)
|
||||
}
|
||||
if (Date.now() - startedAt >= timeoutMs) {
|
||||
throw new DetectionJobError(
|
||||
'De detectietaak loopt nog op de server, maar de wachttijd in dit scherm is verstreken. Herlaad de bewaarde detectieruns om het resultaat later te bekijken.',
|
||||
'DETECTION_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 existing UI summary contract. */
|
||||
export function completedDetectionResponse(
|
||||
request: DetectionRunRequest,
|
||||
job: JobRead,
|
||||
run: DetectionRunRead,
|
||||
): DetectionRunResponse {
|
||||
assertDetectionJobIdentity(request.project_id, job)
|
||||
if (
|
||||
job.status !== 'success'
|
||||
|| run.status !== 'success'
|
||||
|| run.project_id !== request.project_id
|
||||
|| run.dataset_id !== request.dataset_id
|
||||
|| run.job_id !== job.id
|
||||
) {
|
||||
throw new DetectionJobError(
|
||||
'De bewaarde detectierun komt niet overeen met de voltooide GPU-taak',
|
||||
'DETECTION_RUN_RESULT_MISMATCH',
|
||||
job.id,
|
||||
)
|
||||
}
|
||||
|
||||
const detectionCount = numberValue(job.result_json, 'detection_count')
|
||||
?? numberValue(run.result_json, 'detection_count')
|
||||
if (detectionCount === null || !Number.isInteger(detectionCount) || detectionCount < 0) {
|
||||
throw new DetectionJobError(
|
||||
'De voltooide detectietaak bevat geen geldige, herleidbare objecttelling',
|
||||
'DETECTION_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',
|
||||
detection_count: detectionCount,
|
||||
error_code: null,
|
||||
message: detectionCount === 0
|
||||
? 'Analyse voltooid zonder objecten boven de gekozen zekerheidsdrempel. Dit bewijst niet dat het gebied objectvrij is.'
|
||||
: 'GPU-analyse voltooid; de bewaarde objecten zijn geladen.',
|
||||
}
|
||||
}
|
||||
|
||||
export function analysisRunIdFromJob(job: JobRead): string | null {
|
||||
return stringValue(job.result_json, 'analysis_run_id')
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { JobRead, SegmentationRunRead, SegmentationRunRequest } from '../types'
|
||||
import {
|
||||
completedSegmentationResponse,
|
||||
SegmentationJobError,
|
||||
waitForSegmentationJob,
|
||||
} from './segmentationJob'
|
||||
|
||||
const projectId = 'project-1'
|
||||
const datasetId = 'dataset-1'
|
||||
const jobId = 'job-1'
|
||||
|
||||
function job(status: string, overrides: Partial<JobRead> = {}): JobRead {
|
||||
return {
|
||||
id: jobId,
|
||||
job_type: 'segmentation.run',
|
||||
status,
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
parameters_json: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function run(overrides: Partial<SegmentationRunRead> = {}): SegmentationRunRead {
|
||||
return {
|
||||
id: 'run-1',
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
job_id: jobId,
|
||||
analysis_type: 'segmentation',
|
||||
status: 'success',
|
||||
model_name: 'yolo-seg-configured',
|
||||
parameters_json: {},
|
||||
result_json: { segmentation_count: 4 },
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
const request: SegmentationRunRequest = {
|
||||
project_id: projectId,
|
||||
dataset_id: datasetId,
|
||||
model_id: 'yolo-seg-configured',
|
||||
confidence_threshold: 0.5,
|
||||
tile_manifest_path: '/tiles/manifest.json',
|
||||
}
|
||||
|
||||
describe('waitForSegmentationJob', () => {
|
||||
it('polls the project-bound job until the GPU task succeeds', async () => {
|
||||
const readJob = vi.fn()
|
||||
.mockResolvedValueOnce(job('running'))
|
||||
.mockResolvedValueOnce(job('success', { result_json: { segmentation_count: 4 } }))
|
||||
const statuses: string[] = []
|
||||
|
||||
const completed = await waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: job('queued'),
|
||||
intervalMs: 0,
|
||||
readJob,
|
||||
onStatus: (value) => statuses.push(value.status),
|
||||
})
|
||||
|
||||
expect(completed.status).toBe('success')
|
||||
expect(statuses).toEqual(['queued', 'running', 'success'])
|
||||
expect(readJob).toHaveBeenNthCalledWith(1, projectId, jobId)
|
||||
expect(readJob).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('keeps server failure and timeout distinct from a valid empty result', async () => {
|
||||
await expect(waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: job('failed', {
|
||||
error_message: 'NVIDIA CUDA is niet beschikbaar',
|
||||
result_json: { error_code: 'SEGMENTATION_ACCELERATOR_UNAVAILABLE' },
|
||||
}),
|
||||
intervalMs: 0,
|
||||
})).rejects.toMatchObject({
|
||||
name: 'SegmentationJobError',
|
||||
code: 'SEGMENTATION_ACCELERATOR_UNAVAILABLE',
|
||||
message: 'NVIDIA CUDA is niet beschikbaar',
|
||||
})
|
||||
|
||||
await expect(waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: job('running'),
|
||||
intervalMs: 0,
|
||||
timeoutMs: 0,
|
||||
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_POLL_TIMEOUT' })
|
||||
})
|
||||
|
||||
it('rejects partial, cross-project and wrong-task jobs', async () => {
|
||||
await expect(waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: job('partial'),
|
||||
intervalMs: 0,
|
||||
})).rejects.toBeInstanceOf(SegmentationJobError)
|
||||
|
||||
await expect(waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: job('success', { project_id: 'other-project' }),
|
||||
intervalMs: 0,
|
||||
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_IDENTITY_MISMATCH' })
|
||||
|
||||
await expect(waitForSegmentationJob({
|
||||
projectId,
|
||||
initialJob: job('success', { job_type: 'detection.run' }),
|
||||
intervalMs: 0,
|
||||
})).rejects.toMatchObject({ code: 'SEGMENTATION_JOB_IDENTITY_MISMATCH' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('completedSegmentationResponse', () => {
|
||||
it('accepts a persisted zero-result run without claiming that the area is empty', () => {
|
||||
const response = completedSegmentationResponse(
|
||||
request,
|
||||
job('success', { result_json: { segmentation_count: 0 } }),
|
||||
run({ result_json: { segmentation_count: 0 } }),
|
||||
)
|
||||
|
||||
expect(response.segmentation_count).toBe(0)
|
||||
expect(response.status).toBe('success')
|
||||
expect(response.message).toContain('bewijst niet')
|
||||
})
|
||||
|
||||
it('fails closed for missing counts or a mismatched persisted run', () => {
|
||||
expect(() => completedSegmentationResponse(
|
||||
request,
|
||||
job('success'),
|
||||
run({ result_json: null }),
|
||||
)).toThrowError(SegmentationJobError)
|
||||
|
||||
expect(() => completedSegmentationResponse(
|
||||
request,
|
||||
job('success', { result_json: { segmentation_count: 2 } }),
|
||||
run({ project_id: 'other-project' }),
|
||||
)).toThrowError(SegmentationJobError)
|
||||
|
||||
expect(() => completedSegmentationResponse(
|
||||
request,
|
||||
job('success', { result_json: { segmentation_count: 2 } }),
|
||||
run({ model_name: 'sam-configured' }),
|
||||
)).toThrowError(SegmentationJobError)
|
||||
})
|
||||
})
|
||||
@@ -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