From 6b8f58e1a0c2b13ab3178c10304a2c189ed4de77 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:27:57 +0900 Subject: [PATCH 01/23] =?UTF-8?q?docs:=20VOD=20=EC=9E=91=EC=97=85=20API=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/openapi.yaml | 763 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 763 insertions(+) create mode 100644 api/vod/openapi.yaml diff --git a/api/vod/openapi.yaml b/api/vod/openapi.yaml new file mode 100644 index 0000000..94ad9e8 --- /dev/null +++ b/api/vod/openapi.yaml @@ -0,0 +1,763 @@ +openapi: 3.0.3 +info: + title: InnoLive VOD 작업 API + version: 0.1.0 + description: | + # 범위와 근거 + + 이슈 #521의 1단계 계약이며 아직 구현되지 않았다. 서버의 기존 HTTP 주소 체계에 + 맞춰 초안의 `/api/v1/vod/jobs`를 `/vod/jobs`로 확정한다. `/api/v1` 별칭이나 + 프록시 rewrite는 정의하지 않는다. JSON 필드는 snake_case이고 성공 응답은 + data/success 래퍼 없이 반환한다. 기존 OpenAPI 문서가 없어 api/ 아래에 추가한다. + + 근거(작성 시점 소스): + - internal/server/server.go:112-143: net/http ServeMux, 접두사 없는 리소스 경로와 RequireUser 래핑. + - internal/auth/user_middleware.go:52-79,117-149: 활성 사용자 Bearer 인증, 컨텍스트 UUID, 인증 오류 형식. + - internal/server/server.go:1470-1479,1535-1581: X-Request-ID, error 래퍼, 선택적 details와 request_id, 직접 JSON 응답. + - internal/server/reference_face.go:138-153,301-329: snake_case, time.Time, multipart 업로드와 본문 크기 제한. + - internal/server/server.go:1499-1529: Origin 허용 목록과 OPTIONS/CORS 처리. + - internal/server/session_auth.go:44-62: 기존 세션 소유권 검증. VOD는 사용자 UUID만 사용하며 세션 owner token은 요구하지 않는다. + - internal/ai/pool.go:155-173: 라이브 스트림 워커 배정. VOD 작업 큐나 체크포인트 구현은 아님. + - api/proto/ai_processor.proto:6-26 및 읽기 전용 innolive-ai/protos/ai_processor.proto:6-25: 프레임 스트림 RPC, VOD 작업 RPC 없음. + - /workspace/innolive-video/plan.md §3.2-0, §3.2-1, §3.5: 기존 AI 워커 재사용, 신규 작업 큐, 라이브 우선. + - https://github.com/team-framework/innolive-server/issues/521: 로컬 디스크 저장, 실패·취소를 포함한 원본 파기 기한. + + 인증은 기존 로그인에서 발급한 InnoLive access token을 사용한다. 모든 작업 조회, + 다운로드, 삭제는 인증 컨텍스트의 user_id와 job_id를 함께 조건으로 적용한다. + 요청에 user_id를 받지 않으며 세션 토큰, 게스트 쿠키, 외부 OAuth 토큰은 대체할 수 없다. + 목록에는 본인 작업만 포함한다. 타인 작업과 없는 작업은 모두 404/not_found로 응답한다. + 이는 기존 세션의 소유권 불일치 403과 달리 VOD 작업 존재 여부도 감추기 위한 선택이다. + 작업 식별자의 UUID 문법 오류는 400이며 인증 검증을 먼저 수행한다. + + # 업로드와 설정 계약 + + multipart/form-data의 `file` 파트 하나만 허용한다. 빈 파일, 여러 파일, 중복 file, + 알 수 없는 파트는 400/bad_request이다. 파일 이름은 표시 용도로만 사용하며 경로나 + 작업 식별자로 사용하지 않는다. 브라우저가 multipart boundary를 생성해야 한다. + MVP 입력은 MP4 컨테이너의 H.264 영상과 AAC 오디오(또는 무음)이다. 파일 파트의 + Content-Type은 video/mp4 또는 application/octet-stream을 허용하되 실제 바이트를 + 검사한다. 지원하지 않는 컨테이너/코덱은 415/unsupported_media_type, + 손상되거나 영상 트랙·유효한 길이가 없는 파일은 422/invalid_media이다. + 결과는 MP4/H.264이며 AAC 오디오는 무변환 복사한다. 확장자나 MIME만 신뢰하지 않는다. + + 다음 이름은 신규 설정 계약이며 아직 internal/config에 존재하지 않는다. + - VOD_MAX_UPLOAD_BYTES: 파일 바이트 상한 M, 양의 정수. 파일 크기는 M 이하. + - VOD_MAX_DURATION_SEC: 서버가 조사한 영상 길이 상한 D, 양의 정수 초. 0 < 길이 <= D. + - 전체 multipart 본문 상한: M + 1048576 바이트(파트 헤더/boundary 여유 포함). + Content-Length 유무와 관계없이 실제 스트림에서 두 상한을 검사한다. + - VOD_ORIGINAL_RETENTION_SEC: 종료 후 원본 보존 상한, 정수 0..86400초, 기본 86400. + 0은 종료 직후 파기이다. 스위퍼 주기를 더해 기한을 넘겨서는 안 된다. + - VOD_RESULT_RETENTION_SEC: 성공 완료 후 결과 보존 기간, 양의 정수 초. + 무기한 보관이나 다운로드 시 기한 연장은 허용하지 않는다. + M, D, 결과 보존 기간은 운영값 미정으로, VOD 활성화 시 명시 설정이 필수이다. + 상한 초과 오류 응답의 details에 적용 상한(max_upload_bytes, max_request_bytes, + max_duration_sec)을 담는다. 설정 변경은 이미 확정된 삭제/만료 기한을 연장하지 않는다. + 업로드 읽기 기한은 전역 WebSocket/SSE timeout과 분리하며 실제 값은 구현 단계에서 결정한다. + + POST는 파일 수신·미디어 검증·원본 및 작업의 영속 저장이 끝난 뒤 201을 반환한다. + 실패한 업로드는 사용자에게 보이는 작업을 만들지 않고 임시 파일을 정리한다. + 201 시점에는 uploaded 또는 queued이며 처리 완료를 기다리지 않는다. + uploaded는 업로드 중이라는 뜻이 아니라 스케줄러 전달 전이라는 뜻이다. + 작업 수락 후 일시적인 큐 장애는 uploaded 상태에서 재시도한다. 영속 수락 불가는 + 503/service_unavailable이다. 라이브가 바쁘다는 이유만으로 업로드를 거절하지 않는다. + POST의 멱등성 키/재개 업로드는 이번 계약에 없다. 응답 유실 후 재전송은 새 작업이므로 + 클라이언트는 목록을 먼저 확인하고 자동 재업로드하지 않는다. + + # 상태와 진행률 + + | 현재 | 다음 | 조건 | + | --- | --- | --- | + | uploaded | queued, failed | 큐 등록 성공 또는 복구 불가 오류 | + | queued | processing, failed | 라이브가 쓰지 않는 워커/GPU 확보 또는 복구 불가 오류 | + | processing | paused, done, failed | 라이브 양보/지연 가드/일시적 워커 장애, 결과 저장 완료, 복구 불가 오류 | + | paused | queued, failed | 재개 가능하여 기존 순번으로 복귀 또는 복구 불가 오류 | + | done | expired | result_expires_at 도달 | + | failed, expired | 없음 | 재실행은 새 업로드 | + + 어느 상태에서도 DELETE는 취소와 논리 삭제를 수행한다. 삭제된 작업은 목록에서 + 빠지고 상세/결과는 404이다. 별도 canceled 상태나 공개 tombstone은 추가하지 않는다. + 서버 내부에서는 취소 시각과 정리 의무를 보존하며 늦게 도착한 워커 결과가 작업을 + 되살릴 수 없다. 반복 DELETE는 404이지만 삭제 효과는 멱등이다. + + 라이브가 항상 우선이다. 유휴 자원에서만 VOD를 실행하고, GPU당 최대 1개 작업을 + 배정한다(초기 정책, 실측 후 조정 가능). 라이브 수요/지연 가드 발생 시 새 구간을 + 시작하지 않고 자원을 양보한다. 구간 체크포인트부터 재개하며 중단된 미확정 구간은 + 다시 처리할 수 있다. 라이브를 장시간 구간 완료까지 기다리게 해서는 안 된다. + 사용자 pause/resume API는 없다. paused는 서버 스케줄러가 관리한다. + + VOD 간 순서는 최초 큐 진입 시각과 job_id의 오름차순(FIFO)이며 재개 시 순서를 + 유지한다. queue_position은 전체 VOD 대기열(queued와 paused)의 1부터 시작하는 + 순번이다. 다른 사용자의 ID/파일명은 노출하지 않는다. 라이브는 이 순번에 포함하지 + 않으며 모든 VOD보다 우선한다. 자원 상태에 따라 실제 시작 순서는 달라질 수 있다. + 큐 스냅샷을 얻지 못하면 queue_position은 null이다. + eta_sec는 현재부터 결과 완료까지의 추정 초(대기 + 남은 처리/결과 저장)이다. + 라이브 부하에 따라 늘거나 줄며 보장 시간이 아니다. 예측 불가 시 null이다. + 라이브 장기 점유로 대기/일시정지가 무기한 길어질 수 있으며 종료 전 24시간 삭제 + 타이머는 시작하지 않는다. 최대 대기/처리 시간 정책은 별도 운영 결정이다. + + progress는 업로드 전송률과 별개인 0..100 정수 백분율이다. 확정 체크포인트의 + 처리량 기준으로 단조 증가하고 재처리/일시정지 시 감소하지 않는다. uploaded/queued의 + 최초 값은 0이며, 재개 대기 시 기존 값을 유지한다. 결과가 영속 저장되기 전 최대 99, + done/expired는 100, failed는 마지막 값이다. 목록/상세를 5초 간격으로 폴링한다. + 상태 변화가 빠르면 중간 상태를 관찰하지 못할 수 있다. + + # 보존과 시간 + + 모든 *_at은 UTC RFC 3339 문자열(Z, 소수 초 허용)이다. created_at/updated_at은 + 항상 문자열이고 종료/삭제/만료 시각은 미정이면 명시적인 null이다. 필드를 생략하지 + 않는다. null은 아직 정해지지 않았음을 뜻하며 무기한 보관 허가가 아니다. + completed_at은 done 또는 failed 진입 시각이고 expired에서도 유지한다. + original_delete_at은 completed_at + VOD_ORIGINAL_RETENTION_SEC 이하의 파기 기한이다. + 실제 파기가 빨리 끝나도 기한 필드와 작업 상태는 유지한다(파기 완료 표시가 아님). + 실패도 동일하게 적용한다. DELETE 시 원본 파기 기한은 기존 기한과 삭제 수락 시각 + + 보존 상한 중 더 이른 값이며 결과·임시 파일도 삭제 수락 후 24시간 이내 정리한다. + DELETE 204는 접근 차단과 취소/정리 의무의 영속화를 뜻하며 물리 파기 완료를 뜻하지 않는다. + 원본 복사본과 중간 체크포인트 정리는 원본 기한을 따르고 파기 로그를 남겨야 한다. + + result_expires_at은 done 진입 시 completed_at + VOD_RESULT_RETENTION_SEC로 고정한다. + 해당 시각까지 결과 파일을 파기한다. 해당 시각부터 신규 다운로드는 + 410/result_expired이며 정리 스위퍼 실행 여부와 무관하게 접근을 차단한다. + 만료 전 시작한 다운로드도 만료 또는 DELETE 시 중단한다. expired 작업의 메타데이터는 + 사용자가 DELETE할 때까지 목록/상세에 남으며 원본 삭제만으로 expired가 되지 않는다. + 실패 작업의 result_expires_at은 null이다. 완료되지 않은/부분 결과나 원본은 다운로드할 수 없다. + + # HTTP 공통 규칙과 다운로드 + + 정상 API 및 오류 응답은 Cache-Control: no-store와 X-Request-ID를 반환한다. + X-Request-ID 요청 헤더는 기존 미들웨어가 그대로 전달하거나 생성한다. + error.message는 표시용이며 분기는 HTTP 상태와 error.code로 수행한다. + request_id와 error.details는 선택적이다. 기존 인증 미들웨어의 오류에는 request_id가 없다. + 정의한 작업 API 오류는 JSON이며, 미등록 경로/미지원 메서드는 기존 ServeMux의 + 404/405 text/plain 동작을 유지한다. 클라이언트는 비JSON 프록시 오류도 처리해야 한다. + + GET result는 인증 후 파일 바이트를 직접 반환한다. URL JSON이나 외부 저장소로의 + 리다이렉트는 없다. Content-Disposition은 attachment이고 다운로드 이름은 서버가 + 생성한 ASCII `vod-{job_id}.mp4`이다. 원본 파일명을 헤더에 사용하지 않는다. + Content-Length는 실제 결과 바이트 수, Content-Type은 video/mp4, + X-Content-Type-Options는 nosniff, Accept-Ranges는 none이다. + 이번 계약에는 부분 다운로드가 없으며 Range 헤더를 보내도 전체 파일의 200을 반환한다. + 스트림 시작 이후 저장소 오류가 나면 연결을 중단하며 JSON 오류를 파일 뒤에 쓰지 않는다. + + 랜딩 웹의 Origin은 기존 허용 목록에 등록해야 한다. 기존 OPTIONS/CORS 처리를 재사용하고 + Authorization, Content-Type, X-Request-ID 요청 헤더를 허용한다. 브라우저가 읽을 수 + 있도록 X-Request-ID 외에 Location, Content-Disposition, Content-Length, Retry-After를 + 노출해야 한다(현재는 X-Request-ID만 노출하므로 구현 단계 변경 필요). + 토큰을 URL query에 넣지 않는다. 다운로드도 Authorization 헤더가 있는 요청이어야 한다. + + # TypeScript 클라이언트 타입 예시 + + 아래는 wire 형식 예시이며 생성된 SDK가 아니다. 날짜 문자열은 클라이언트에서 파싱한다. + + ```ts + type VodJobStatus = + | 'uploaded' | 'queued' | 'processing' | 'paused' + | 'done' | 'failed' | 'expired'; + type VodFailureCode = 'processing_failed' | 'storage_failed'; + interface VodJob { + job_id: string; + status: VodJobStatus; + progress: number; + queue_position: number | null; + eta_sec: number | null; + created_at: string; + updated_at: string; + completed_at: string | null; + original_delete_at: string | null; + result_expires_at: string | null; + failure: { code: VodFailureCode; message: string } | null; + } + interface VodJobList { jobs: VodJob[]; next_cursor: string | null } + interface VodListQuery { limit?: number; cursor?: string } + interface VodUpload { file: File } + interface ApiError { + error: { code: string; message: string; details?: Record }; + request_id?: string; + } + // wire의 snake_case 유지. camelCase 변환은 UI 계층에서 처리한다. + async function uploadVod(baseURL: string, accessToken: string, file: File): Promise { + const body = new FormData(); + body.append('file', file); + const response = await fetch(`${baseURL}/vod/jobs`, { + method: 'POST', headers: { Authorization: `Bearer ${accessToken}` }, body, + }); + // Content-Type은 직접 설정하지 않는다. 브라우저가 boundary를 붙인다. + if (!response.ok) throw new Error(`VOD upload failed: ${response.status}`); + return await response.json() as VodJob; + } + // GET /vod/jobs -> VodJobList, GET /vod/jobs/{job_id} -> VodJob + // GET /vod/jobs/{job_id}/result -> binary stream (small files may use Blob) + // DELETE /vod/jobs/{job_id} -> 204, body 없음 + ``` + + # 남은 운영·구현 결정 + + - 업로드 최대 바이트/길이와 결과 보존 기간의 실제 값, 업로드 읽기 기한. + - 이슈에서 정한 로컬 디스크의 경로·용량·스위퍼 주기와 실패 재시도/파기 로그 운영 방식. + - AI VOD 어댑터/RPC, 구간 길이·체크포인트 형식, GPU 매핑·메모리/지연 가드 임계값 및 ETA 실측. + - 장기 대기/실패 메타데이터의 보관 상한과 계정 탈퇴 시 정리 연결(접근은 기존 인증에서 차단). + 이 문서는 구현, 운영 검증, 배포를 완료했다는 의미가 아니다. +servers: + - url: / + description: 기존 InnoLive HTTP 서버 origin +security: + - bearerAuth: [] +tags: + - name: VOD + description: 로그인 사용자별 VOD 비식별 작업 +paths: + /vod/jobs: + post: + tags: [VOD] + operationId: createVodJob + summary: 영상 업로드 및 작업 생성 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + additionalProperties: false + required: [file] + properties: + file: + type: string + format: binary + description: 파일 하나, 0 < 바이트 수 <= VOD_MAX_UPLOAD_BYTES. 길이 <= VOD_MAX_DURATION_SEC. + encoding: + file: + contentType: video/mp4, application/octet-stream + responses: + '201': + description: 업로드 검증과 영속 저장 완료. status는 uploaded 또는 queued. + headers: + Location: + description: 생성한 작업의 상대 경로 /vod/jobs/{job_id} + schema: + type: string + example: /vod/jobs/8f46f610-3207-4b86-8a22-18a1eeefb781 + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + content: + application/json: + schema: + $ref: '#/components/schemas/VodJob' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenOrigin' + '409': + $ref: '#/components/responses/WithdrawalInProgress' + '413': + $ref: '#/components/responses/UploadTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMedia' + '422': + $ref: '#/components/responses/InvalidMedia' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' + get: + tags: [VOD] + operationId: listVodJobs + summary: 본인 작업 목록 조회 + description: | + created_at DESC, job_id DESC 순서의 커서 페이지네이션. 삭제된 작업은 제외하고 + failed/expired는 포함한다. 첫 페이지의 상한과 마지막 정렬 키를 담은 opaque cursor로 + 다음 페이지를 조회하여 페이지 이동 중 추가된 작업이 끼어들지 않도록 한다. + cursor는 사용자에 바인딩하며 다른 사용자/변조/문법 오류 cursor는 400/bad_request이다. + 상태/진행률은 각 요청 시점의 값이며 고정 스냅샷이 아니다. 삭제된 항목은 건너뛴다. + 마지막 페이지는 next_cursor=null, 빈 목록은 jobs=[]이다. total은 제공하지 않는다. + 저장소에 기존 페이지네이션 규칙이 없어 이 API에 명시적으로 도입한다. + parameters: + - name: limit + in: query + description: 페이지 크기. 정수가 아니거나 범위 밖이면 400이며 자동 보정하지 않는다. + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: cursor + in: query + description: 직전 응답의 next_cursor. 첫 요청에서는 생략하며 빈 문자열은 허용하지 않는다. + schema: + type: string + minLength: 1 + responses: + '200': + description: 본인 작업 페이지 + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + content: + application/json: + schema: + $ref: '#/components/schemas/VodJobList' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenOrigin' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' + /vod/jobs/{job_id}: + parameters: + - $ref: '#/components/parameters/JobID' + get: + tags: [VOD] + operationId: getVodJob + summary: 본인 작업 상태 조회 + description: expired/failed도 200으로 조회 가능하다. 원본 파일이나 저장소 경로는 노출하지 않는다. + responses: + '200': + $ref: '#/components/responses/Job' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenOrigin' + '404': + $ref: '#/components/responses/NotFound' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' + delete: + tags: [VOD] + operationId: deleteVodJob + summary: 본인 작업 취소 및 삭제 + description: | + 모든 상태에서 허용한다. 접근 차단, 스케줄링 취소, 파기 의무를 영속화한 뒤 204. + 파일의 물리 파기는 비동기이며 기한은 공통 보존 계약을 따른다. + 삭제 후 GET/다운로드/반복 DELETE는 404. 요청 본문은 없다. + responses: + '204': + description: 논리 삭제 완료. 응답 본문 없음. + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenOrigin' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/WithdrawalInProgress' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' + /vod/jobs/{job_id}/result: + parameters: + - $ref: '#/components/parameters/JobID' + get: + tags: [VOD] + operationId: downloadVodResult + summary: 비식별 결과 파일 다운로드 + description: | + 본인 소유이며 done이고 현재 시각 < result_expires_at인 작업만 다운로드 가능하다. + uploaded/queued/processing/paused/failed는 409/result_not_ready, + expired 또는 만료 시각 경과는 410/result_expired이다. + 타인 작업/삭제된 작업/없는 작업은 상태보다 먼저 판정하여 404이다. + 만료 전 파일을 일시적으로 읽을 수 없으면 503/service_unavailable이며 원본으로 대체하지 않는다. + responses: + '200': + description: 전체 결과 MP4 바이너리 스트림 + headers: + Content-Disposition: + description: 서버 생성 ASCII 파일명, 원본 파일명 미사용. + schema: + type: string + example: attachment; filename="vod-8f46f610-3207-4b86-8a22-18a1eeefb781.mp4" + Content-Length: + description: 결과 파일의 실제 바이트 수 + schema: + type: integer + format: int64 + minimum: 1 + Accept-Ranges: + schema: + type: string + enum: [none] + X-Content-Type-Options: + schema: + type: string + enum: [nosniff] + Cache-Control: + $ref: '#/components/headers/NoStore' + X-Request-ID: + $ref: '#/components/headers/RequestID' + content: + video/mp4: + schema: + type: string + format: binary + '400': + $ref: '#/components/responses/BadRequest' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/ForbiddenOrigin' + '404': + $ref: '#/components/responses/NotFound' + '409': + $ref: '#/components/responses/ResultNotReady' + '410': + $ref: '#/components/responses/ResultExpired' + '429': + $ref: '#/components/responses/RateLimited' + '500': + $ref: '#/components/responses/InternalError' + '503': + $ref: '#/components/responses/Unavailable' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: 기존 InnoLive 로그인 access token. RequireUser로 활성 사용자 확인. + parameters: + JobID: + name: job_id + in: path + required: true + description: 서버가 발급한 작업 UUID. 문법 오류는 400, 본인 작업이 아니거나 없으면 404. + schema: + type: string + format: uuid + headers: + RequestID: + description: 요청의 X-Request-ID 또는 서버 생성값 + schema: + type: string + NoStore: + schema: + type: string + enum: [no-store] + RetryAfter: + description: 재시도까지 기다릴 양의 정수 초. 429에는 필수, 일시적 503에는 제공 가능. + schema: + type: integer + minimum: 1 + schemas: + VodJobStatus: + type: string + enum: [uploaded, queued, processing, paused, done, failed, expired] + VodJob: + type: object + required: + - job_id + - status + - progress + - queue_position + - eta_sec + - created_at + - updated_at + - completed_at + - original_delete_at + - result_expires_at + - failure + properties: + job_id: + type: string + format: uuid + status: + $ref: '#/components/schemas/VodJobStatus' + progress: + type: integer + minimum: 0 + maximum: 100 + description: 확정 처리 진행률 %. 업로드 전송률과 별개이며 done/expired만 100. + queue_position: + type: integer + minimum: 1 + nullable: true + description: queued/paused의 전체 VOD 대기 순번. 다른 상태 또는 큐 조회 불가 시 null. + eta_sec: + type: integer + format: int64 + minimum: 0 + nullable: true + description: 결과 완료까지 추정 초. uploaded/failed/expired 및 예측 불가 시 null, done은 0. + created_at: + type: string + format: date-time + description: 업로드 수락 시각. UTC RFC 3339. + updated_at: + type: string + format: date-time + description: 영속 작업 상태/진행률/기한의 마지막 변경 시각. 실시간 순번/ETA 변화는 제외. + completed_at: + type: string + format: date-time + nullable: true + description: done/failed 진입 시각. expired에서 유지. 종료 전 null. + original_delete_at: + type: string + format: date-time + nullable: true + description: 원본 파기 기한(완료/실패 후 최대 24시간). 종료 전 null. + result_expires_at: + type: string + format: date-time + nullable: true + description: 결과 다운로드 만료 시각. done/expired 이외에는 null. + failure: + type: object + nullable: true + description: failed에서만 오류 객체, 그 외 null. 내부 경로/워커 주소/민감 데이터 미포함. + required: [code, message] + properties: + code: + type: string + enum: [processing_failed, storage_failed] + message: + type: string + example: + job_id: 8f46f610-3207-4b86-8a22-18a1eeefb781 + status: paused + progress: 37 + queue_position: 1 + eta_sec: null + created_at: '2026-10-26T08:00:00Z' + updated_at: '2026-10-26T08:10:00Z' + completed_at: null + original_delete_at: null + result_expires_at: null + failure: null + VodJobList: + type: object + required: [jobs, next_cursor] + properties: + jobs: + type: array + maxItems: 100 + items: + $ref: '#/components/schemas/VodJob' + next_cursor: + type: string + minLength: 1 + nullable: true + description: 다음 페이지 opaque cursor. 마지막 페이지에서는 null. + example: + jobs: [] + next_cursor: null + ApiError: + type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: + type: string + description: 응답별 description에 정의한 안정적인 오류 코드 + message: + type: string + description: 사용자 표시용 설명. 문자열 비교로 분기하지 않는다. + details: + type: object + additionalProperties: true + request_id: + type: string + description: 서버 writeError에서 제공. 인증 미들웨어 등에서는 생략 가능. + example: + error: + code: upload_too_large + message: Video exceeds the configured upload limit. + details: + max_upload_bytes: 1073741824 + max_request_bytes: 1074790400 + request_id: 5ac51bac-c82d-41fa-9dc7-dce292abf593 + responses: + Job: + description: 작업 상세 + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + content: + application/json: + schema: + $ref: '#/components/schemas/VodJob' + BadRequest: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: bad_request — 잘못된 UUID/쿼리/multipart, 빈 파일 또는 file 파트 개수 오류. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + Unauthorized: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: unauthorized — 토큰 누락/무효/만료 또는 비활성 사용자. 기존 인증 오류 사용. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + ForbiddenOrigin: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: origin_not_allowed — 허용되지 않은 Origin. 타인 작업에 사용하는 상태 코드가 아님. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + NotFound: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: not_found — 없는 작업, 삭제한 작업, 타인 작업 모두 동일한 코드/메시지. 소유자 정보 미포함. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + WithdrawalInProgress: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: withdrawal_in_progress — 계정 탈퇴 중 작업 변경 차단. 기존 사용자 작업 게이트 재사용. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + UploadTooLarge: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: | + upload_too_large — 파일 크기 또는 전체 요청 본문 상한 초과. + details.max_upload_bytes와 details.max_request_bytes에 적용 상한 제공. + 예시의 수치는 운영 기본값이 아니다. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + UnsupportedMedia: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: unsupported_media_type — multipart가 아닌 요청, 미지원 파일 MIME/컨테이너/코덱. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + InvalidMedia: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: | + invalid_media — 손상 파일, 영상 트랙 부재, 길이를 조사할 수 없거나 길이 <= 0. + duration_exceeded — 영상 길이 상한 초과. details.max_duration_sec에 적용 상한 제공. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + ResultNotReady: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: result_not_ready — uploaded/queued/processing/paused/failed 작업에 결과 요청. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + ResultExpired: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: result_expired — 결과 보존 기한 경과. 작업 상세는 계속 200으로 조회 가능. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + RateLimited: + description: rate_limited — 요청 빈도 제한. Retry-After 초 이후 재시도. 구체적 제한량은 운영 정책. + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + InternalError: + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + description: internal_error — 예상하지 못한 서버 오류 또는 기존 인증 저장소 오류. + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' + Unavailable: + description: | + service_unavailable — 작업 저장소/파일 저장소 등 일시적 장애. + demo_lockdown — 기존 RequireUser 시연 제한 정책. 라이브 자원 점유 자체는 503 사유가 아님. + headers: + X-Request-ID: + $ref: '#/components/headers/RequestID' + Cache-Control: + $ref: '#/components/headers/NoStore' + Retry-After: + $ref: '#/components/headers/RetryAfter' + content: + application/json: + schema: + $ref: '#/components/schemas/ApiError' From 79844aaa664c35271d5683da3e34f620a41c4ae1 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:41:27 +0900 Subject: [PATCH 02/23] =?UTF-8?q?docs:=20VOD=20=EA=B8=B0=EB=B3=B8=20?= =?UTF-8?q?=ED=95=9C=EB=8F=84=EC=99=80=20=EC=9B=8C=EC=BB=A4=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=83=81=ED=83=9C=20=EA=B3=84=EC=95=BD=20=ED=99=95?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/openapi.yaml | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/api/vod/openapi.yaml b/api/vod/openapi.yaml index 94ad9e8..e9f527e 100644 --- a/api/vod/openapi.yaml +++ b/api/vod/openapi.yaml @@ -1,11 +1,11 @@ openapi: 3.0.3 info: title: InnoLive VOD 작업 API - version: 0.1.0 + version: 0.2.0 description: | # 범위와 근거 - 이슈 #521의 1단계 계약이며 아직 구현되지 않았다. 서버의 기존 HTTP 주소 체계에 + 이슈 #521의 API 계약이다. 2단계에서 기본 비활성 VOD 기능을 구현한다. 서버의 기존 HTTP 주소 체계에 맞춰 초안의 `/api/v1/vod/jobs`를 `/vod/jobs`로 확정한다. `/api/v1` 별칭이나 프록시 rewrite는 정의하지 않는다. JSON 필드는 snake_case이고 성공 응답은 data/success 래퍼 없이 반환한다. 기존 OpenAPI 문서가 없어 api/ 아래에 추가한다. @@ -40,7 +40,7 @@ info: 손상되거나 영상 트랙·유효한 길이가 없는 파일은 422/invalid_media이다. 결과는 MP4/H.264이며 AAC 오디오는 무변환 복사한다. 확장자나 MIME만 신뢰하지 않는다. - 다음 이름은 신규 설정 계약이며 아직 internal/config에 존재하지 않는다. + 다음 이름은 서버 설정 계약이다. VOD_ENABLED=false가 기본이며 꺼져 있으면 라우트와 백그라운드 작업을 등록하지 않는다. - VOD_MAX_UPLOAD_BYTES: 파일 바이트 상한 M, 양의 정수. 파일 크기는 M 이하. - VOD_MAX_DURATION_SEC: 서버가 조사한 영상 길이 상한 D, 양의 정수 초. 0 < 길이 <= D. - 전체 multipart 본문 상한: M + 1048576 바이트(파트 헤더/boundary 여유 포함). @@ -49,7 +49,7 @@ info: 0은 종료 직후 파기이다. 스위퍼 주기를 더해 기한을 넘겨서는 안 된다. - VOD_RESULT_RETENTION_SEC: 성공 완료 후 결과 보존 기간, 양의 정수 초. 무기한 보관이나 다운로드 시 기한 연장은 허용하지 않는다. - M, D, 결과 보존 기간은 운영값 미정으로, VOD 활성화 시 명시 설정이 필수이다. + 기본값은 M=2147483648(2 GiB), D=3600(60분), 결과 보존 기간=604800초(7일)이다. 상한 초과 오류 응답의 details에 적용 상한(max_upload_bytes, max_request_bytes, max_duration_sec)을 담는다. 설정 변경은 이미 확정된 삭제/만료 기한을 연장하지 않는다. 업로드 읽기 기한은 전역 WebSocket/SSE timeout과 분리하며 실제 값은 구현 단계에서 결정한다. @@ -67,15 +67,15 @@ info: | 현재 | 다음 | 조건 | | --- | --- | --- | - | uploaded | queued, failed | 큐 등록 성공 또는 복구 불가 오류 | - | queued | processing, failed | 라이브가 쓰지 않는 워커/GPU 확보 또는 복구 불가 오류 | - | processing | paused, done, failed | 라이브 양보/지연 가드/일시적 워커 장애, 결과 저장 완료, 복구 불가 오류 | - | paused | queued, failed | 재개 가능하여 기존 순번으로 복귀 또는 복구 불가 오류 | + | uploaded | queued, failed, cancelled | 큐 등록 성공 또는 복구 불가 오류 | + | queued | processing, failed, cancelled | 라이브가 쓰지 않는 워커/GPU 확보 또는 복구 불가 오류 | + | processing | paused, done, failed, cancelled | 라이브 양보/지연 가드/일시적 워커 장애, 결과 저장 완료, 복구 불가 오류 | + | paused | queued, failed, cancelled | 재개 가능하여 기존 순번으로 복귀 또는 복구 불가 오류 | | done | expired | result_expires_at 도달 | - | failed, expired | 없음 | 재실행은 새 업로드 | + | failed, cancelled, expired | 없음 | 재실행은 새 업로드 | 어느 상태에서도 DELETE는 취소와 논리 삭제를 수행한다. 삭제된 작업은 목록에서 - 빠지고 상세/결과는 404이다. 별도 canceled 상태나 공개 tombstone은 추가하지 않는다. + 빠지고 상세/결과는 404이다. 삭제 요청은 내부 cancelled로 기록하고 공개 tombstone은 추가하지 않는다. 워커 자체 취소는 cancelled로 공개하며 원본 파기 의무는 failed와 같다. 서버 내부에서는 취소 시각과 정리 의무를 보존하며 늦게 도착한 워커 결과가 작업을 되살릴 수 없다. 반복 DELETE는 404이지만 삭제 효과는 멱등이다. @@ -106,7 +106,7 @@ info: 모든 *_at은 UTC RFC 3339 문자열(Z, 소수 초 허용)이다. created_at/updated_at은 항상 문자열이고 종료/삭제/만료 시각은 미정이면 명시적인 null이다. 필드를 생략하지 않는다. null은 아직 정해지지 않았음을 뜻하며 무기한 보관 허가가 아니다. - completed_at은 done 또는 failed 진입 시각이고 expired에서도 유지한다. + completed_at은 done, failed 또는 cancelled 진입 시각이고 expired에서도 유지한다. original_delete_at은 completed_at + VOD_ORIGINAL_RETENTION_SEC 이하의 파기 기한이다. 실제 파기가 빨리 끝나도 기한 필드와 작업 상태는 유지한다(파기 완료 표시가 아님). 실패도 동일하게 적용한다. DELETE 시 원본 파기 기한은 기존 기한과 삭제 수락 시각 @@ -151,7 +151,7 @@ info: ```ts type VodJobStatus = | 'uploaded' | 'queued' | 'processing' | 'paused' - | 'done' | 'failed' | 'expired'; + | 'done' | 'failed' | 'cancelled' | 'expired'; type VodFailureCode = 'processing_failed' | 'storage_failed'; interface VodJob { job_id: string; @@ -191,9 +191,9 @@ info: # 남은 운영·구현 결정 - - 업로드 최대 바이트/길이와 결과 보존 기간의 실제 값, 업로드 읽기 기한. + - 업로드 읽기 기한은 30분이며 VOD_UPLOAD_TIMEOUT으로 변경 가능. - 이슈에서 정한 로컬 디스크의 경로·용량·스위퍼 주기와 실패 재시도/파기 로그 운영 방식. - - AI VOD 어댑터/RPC, 구간 길이·체크포인트 형식, GPU 매핑·메모리/지연 가드 임계값 및 ETA 실측. + - AI 인터페이스는 interface-A.md의 VodProcessor RPC 계약 사용. GPU 매핑·메모리 임계값과 ETA 실측은 배포 전 확인 필요. - 장기 대기/실패 메타데이터의 보관 상한과 계정 탈퇴 시 정리 연결(접근은 기존 인증에서 차단). 이 문서는 구현, 운영 검증, 배포를 완료했다는 의미가 아니다. servers: @@ -380,7 +380,7 @@ paths: summary: 비식별 결과 파일 다운로드 description: | 본인 소유이며 done이고 현재 시각 < result_expires_at인 작업만 다운로드 가능하다. - uploaded/queued/processing/paused/failed는 409/result_not_ready, + uploaded/queued/processing/paused/failed/cancelled는 409/result_not_ready, expired 또는 만료 시각 경과는 410/result_expired이다. 타인 작업/삭제된 작업/없는 작업은 상태보다 먼저 판정하여 404이다. 만료 전 파일을 일시적으로 읽을 수 없으면 503/service_unavailable이며 원본으로 대체하지 않는다. @@ -467,7 +467,7 @@ components: schemas: VodJobStatus: type: string - enum: [uploaded, queued, processing, paused, done, failed, expired] + enum: [uploaded, queued, processing, paused, done, failed, cancelled, expired] VodJob: type: object required: @@ -706,7 +706,7 @@ components: $ref: '#/components/headers/RequestID' Cache-Control: $ref: '#/components/headers/NoStore' - description: result_not_ready — uploaded/queued/processing/paused/failed 작업에 결과 요청. + description: result_not_ready — uploaded/queued/processing/paused/failed/cancelled 작업에 결과 요청. content: application/json: schema: From 07c7fbb516487538030721b6269c70d281ca0999 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:43:00 +0900 Subject: [PATCH 03/23] =?UTF-8?q?feat:=20=EA=B8=B0=EB=B3=B8=20=EB=B9=84?= =?UTF-8?q?=ED=99=9C=EC=84=B1=20VOD=20=EC=84=A4=EC=A0=95=EA=B3=BC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=95=9C=EB=8F=84=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/config/config.go | 10 ++++ internal/config/vod.go | 110 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 internal/config/vod.go diff --git a/internal/config/config.go b/internal/config/config.go index d7cf396..280de5c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -65,6 +65,7 @@ const ( ) type Config struct { + VOD VODConfig HTTPAddr string PrivacyMode PrivacyMode PrivacyFixedDelay time.Duration @@ -248,6 +249,12 @@ func Load() (Config, error) { ), } + vodConfig, vodErr := loadVOD() + if vodErr != nil { + return Config{}, vodErr + } + cfg.VOD = vodConfig + minPort, err := envUint16("WEBRTC_UDP_PORT_MIN", 50000) if err != nil { return Config{}, err @@ -272,6 +279,9 @@ func Load() (Config, error) { } func (c Config) Validate() error { + if err := c.VOD.Validate(); err != nil { + return err + } switch c.PrivacyMode { case PrivacyModeBypass, PrivacyModeFixedDelay, PrivacyModeReal: default: diff --git a/internal/config/vod.go b/internal/config/vod.go new file mode 100644 index 0000000..9456d40 --- /dev/null +++ b/internal/config/vod.go @@ -0,0 +1,110 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +// GPU identity must identify a physical GPU across all slots on a host. +// Unknown targets fail closed for VOD; live target replacement remains unchanged. +type VODConfig struct { + Enabled bool + Root string + MaxUploadBytes int64 + MaxDuration time.Duration + Concurrency int + MinFreeMiB int64 + ResultRetention time.Duration + OriginalRetention time.Duration + SweepInterval time.Duration + UploadTimeout time.Duration + WorkerGPUs map[string]string +} + +func DefaultVOD() VODConfig { + return VODConfig{Root: "/var/lib/innolive/vod", MaxUploadBytes: 2147483648, MaxDuration: time.Hour, + Concurrency: 1, MinFreeMiB: 4096, ResultRetention: 7 * 24 * time.Hour, OriginalRetention: 24 * time.Hour, + SweepInterval: time.Minute, UploadTimeout: 30 * time.Minute, WorkerGPUs: map[string]string{}} +} + +func loadVOD() (VODConfig, error) { + c := DefaultVOD() + var err error + if raw := os.Getenv("VOD_ENABLED"); raw != "" { + c.Enabled, err = strconv.ParseBool(raw) + if err != nil { + return c, fmt.Errorf("invalid VOD_ENABLED") + } + } + if !c.Enabled { + return c, nil + } + c.Root = env("VOD_ROOT", c.Root) + for _, v := range []struct { + key string + dest *int64 + }{{"VOD_MAX_UPLOAD_BYTES", &c.MaxUploadBytes}, {"VOD_MIN_FREE_MIB", &c.MinFreeMiB}} { + if raw := os.Getenv(v.key); raw != "" { + n, e := strconv.ParseInt(raw, 10, 64) + if e != nil { + return c, fmt.Errorf("invalid %s", v.key) + } + *v.dest = n + } + } + for _, v := range []struct { + key string + dest *time.Duration + }{{"VOD_MAX_DURATION_SEC", &c.MaxDuration}, {"VOD_RESULT_RETENTION_SEC", &c.ResultRetention}, {"VOD_ORIGINAL_RETENTION_SEC", &c.OriginalRetention}} { + if raw := os.Getenv(v.key); raw != "" { + n, e := strconv.ParseInt(raw, 10, 64) + if e != nil || n < 0 || n > 315360000 { + return c, fmt.Errorf("invalid %s", v.key) + } + *v.dest = time.Duration(n) * time.Second + } + } + for _, v := range []struct { + key string + dest *time.Duration + }{{"VOD_SWEEP_INTERVAL", &c.SweepInterval}, {"VOD_UPLOAD_TIMEOUT", &c.UploadTimeout}} { + if raw := os.Getenv(v.key); raw != "" { + n, e := time.ParseDuration(raw) + if e != nil { + return c, fmt.Errorf("invalid %s", v.key) + } + *v.dest = n + } + } + if raw := os.Getenv("VOD_MAX_CONCURRENCY"); raw != "" { + c.Concurrency, err = strconv.Atoi(raw) + if err != nil { + return c, fmt.Errorf("invalid VOD_MAX_CONCURRENCY") + } + } + for _, entry := range splitList(os.Getenv("VOD_WORKER_GPUS")) { + target, gpu, ok := strings.Cut(entry, "=") + if !ok || target == "" || gpu == "" { + return c, fmt.Errorf("VOD_WORKER_GPUS requires target=GPU-UUID entries") + } + c.WorkerGPUs[target] = gpu + } + return c, c.Validate() +} + +func (c VODConfig) Validate() error { + if !c.Enabled { + return nil + } + if !filepath.IsAbs(c.Root) || filepath.Clean(c.Root) == "/" { + return fmt.Errorf("VOD_ROOT must be an absolute dedicated directory") + } + if c.MaxUploadBytes <= 0 || c.MaxUploadBytes > 1<<60 || c.MaxDuration <= 0 || c.Concurrency < 1 || c.MinFreeMiB < 0 || c.ResultRetention <= 0 || c.OriginalRetention < 0 || c.OriginalRetention > 24*time.Hour || c.SweepInterval <= 0 || c.SweepInterval > time.Hour || c.UploadTimeout <= 0 { + return fmt.Errorf("invalid VOD limits or retention settings") + } + return nil +} From 0e14de111281cd3c146d616a42ee776418800b1e Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:44:01 +0900 Subject: [PATCH 04/23] =?UTF-8?q?feat:=20VOD=20=EC=9E=91=EC=97=85=EA=B3=BC?= =?UTF-8?q?=20=ED=8C=8C=EA=B8=B0=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94=EA=B0=80?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/migration/migration.go | 6 +- .../sql/000018_create_vod_jobs.down.sql | 5 + .../sql/000018_create_vod_jobs.up.sql | 34 ++++++ internal/vod/schema.sql | 34 ++++++ internal/vod/store.go | 103 ++++++++++++++++++ 5 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 internal/database/migration/sql/000018_create_vod_jobs.down.sql create mode 100644 internal/database/migration/sql/000018_create_vod_jobs.up.sql create mode 100644 internal/vod/schema.sql create mode 100644 internal/vod/store.go diff --git a/internal/database/migration/migration.go b/internal/database/migration/migration.go index 828cd93..f630b25 100644 --- a/internal/database/migration/migration.go +++ b/internal/database/migration/migration.go @@ -11,6 +11,7 @@ import ( "inno-live-server/internal/config" "inno-live-server/internal/experiencequality" "inno-live-server/internal/usage" + "inno-live-server/internal/vod" "gorm.io/gorm" ) @@ -39,7 +40,10 @@ func Run( if err := experiencequality.AutoMigrate(ctx, db); err != nil { return err } - return analytics.AutoMigrate(ctx, db) + if err := analytics.AutoMigrate(ctx, db); err != nil { + return err + } + return vod.AutoMigrate(ctx, db) case config.DatabaseMigrationModeVersioned: return runVersioned(databaseURL) diff --git a/internal/database/migration/sql/000018_create_vod_jobs.down.sql b/internal/database/migration/sql/000018_create_vod_jobs.down.sql new file mode 100644 index 0000000..e195461 --- /dev/null +++ b/internal/database/migration/sql/000018_create_vod_jobs.down.sql @@ -0,0 +1,5 @@ +-- Disable VOD and stop its processes before rollback. Files must be purged and +-- deletion audit records exported before dropping these VOD-only tables. +DROP TABLE IF EXISTS vod_deletion_logs; +DROP TABLE IF EXISTS vod_jobs; +-- Their indexes are dropped with the tables; no existing table is altered. diff --git a/internal/database/migration/sql/000018_create_vod_jobs.up.sql b/internal/database/migration/sql/000018_create_vod_jobs.up.sql new file mode 100644 index 0000000..a7a5b37 --- /dev/null +++ b/internal/database/migration/sql/000018_create_vod_jobs.up.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS vod_jobs ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL, + status text NOT NULL CHECK (status IN ('uploaded','queued','processing','paused','done','failed','cancelled','expired')), + progress integer NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + queued_at timestamptz NOT NULL, + completed_at timestamptz, + original_delete_at timestamptz, + result_expires_at timestamptz, + deleted_at timestamptz, + duration_sec double precision NOT NULL, + frames_done bigint NOT NULL DEFAULT 0, + frames_total bigint NOT NULL DEFAULT 0, + segments_done integer NOT NULL DEFAULT 0, + processing_fps double precision NOT NULL DEFAULT 0, + checkpoint_token text NOT NULL DEFAULT '', + worker_target text NOT NULL DEFAULT '', + failure_code text NOT NULL DEFAULT '', + version bigint NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS vod_jobs_owner_page ON vod_jobs (user_id, created_at DESC, id DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS vod_jobs_queue ON vod_jobs (queued_at, id) WHERE status IN ('uploaded','queued','paused','processing'); +CREATE INDEX IF NOT EXISTS vod_jobs_retention ON vod_jobs (original_delete_at, result_expires_at); +CREATE TABLE IF NOT EXISTS vod_deletion_logs ( + id bigserial PRIMARY KEY, + job_id uuid NOT NULL, + file_kind text NOT NULL, + path_hash text NOT NULL, + deleted_at timestamptz NOT NULL, + reason text NOT NULL, + UNIQUE (job_id, file_kind, path_hash) +); diff --git a/internal/vod/schema.sql b/internal/vod/schema.sql new file mode 100644 index 0000000..a7a5b37 --- /dev/null +++ b/internal/vod/schema.sql @@ -0,0 +1,34 @@ +CREATE TABLE IF NOT EXISTS vod_jobs ( + id uuid PRIMARY KEY, + user_id uuid NOT NULL, + status text NOT NULL CHECK (status IN ('uploaded','queued','processing','paused','done','failed','cancelled','expired')), + progress integer NOT NULL DEFAULT 0 CHECK (progress BETWEEN 0 AND 100), + created_at timestamptz NOT NULL, + updated_at timestamptz NOT NULL, + queued_at timestamptz NOT NULL, + completed_at timestamptz, + original_delete_at timestamptz, + result_expires_at timestamptz, + deleted_at timestamptz, + duration_sec double precision NOT NULL, + frames_done bigint NOT NULL DEFAULT 0, + frames_total bigint NOT NULL DEFAULT 0, + segments_done integer NOT NULL DEFAULT 0, + processing_fps double precision NOT NULL DEFAULT 0, + checkpoint_token text NOT NULL DEFAULT '', + worker_target text NOT NULL DEFAULT '', + failure_code text NOT NULL DEFAULT '', + version bigint NOT NULL DEFAULT 1 +); +CREATE INDEX IF NOT EXISTS vod_jobs_owner_page ON vod_jobs (user_id, created_at DESC, id DESC) WHERE deleted_at IS NULL; +CREATE INDEX IF NOT EXISTS vod_jobs_queue ON vod_jobs (queued_at, id) WHERE status IN ('uploaded','queued','paused','processing'); +CREATE INDEX IF NOT EXISTS vod_jobs_retention ON vod_jobs (original_delete_at, result_expires_at); +CREATE TABLE IF NOT EXISTS vod_deletion_logs ( + id bigserial PRIMARY KEY, + job_id uuid NOT NULL, + file_kind text NOT NULL, + path_hash text NOT NULL, + deleted_at timestamptz NOT NULL, + reason text NOT NULL, + UNIQUE (job_id, file_kind, path_hash) +); diff --git a/internal/vod/store.go b/internal/vod/store.go new file mode 100644 index 0000000..65b46b3 --- /dev/null +++ b/internal/vod/store.go @@ -0,0 +1,103 @@ +package vod + +import ( + "context" + _ "embed" + "errors" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "gorm.io/gorm/clause" + gormlogger "gorm.io/gorm/logger" +) + +//go:embed schema.sql +var schemaSQL string + +func AutoMigrate(ctx context.Context, db *gorm.DB) error { + return db.WithContext(ctx).Exec(schemaSQL).Error +} + +type Job struct { + ID uuid.UUID `gorm:"type:uuid;primaryKey"` + UserID uuid.UUID `gorm:"type:uuid"` + Status string + Progress int + CreatedAt time.Time + UpdatedAt time.Time + QueuedAt time.Time + CompletedAt *time.Time + OriginalDeleteAt *time.Time + ResultExpiresAt *time.Time + DeletedAt *time.Time + DurationSec float64 + FramesDone uint64 + FramesTotal uint64 + SegmentsDone uint32 + ProcessingFPS float64 `gorm:"column:processing_fps"` + CheckpointToken string + WorkerTarget string + FailureCode string + Version int64 +} + +func (Job) TableName() string { return "vod_jobs" } +func (j Job) Terminal() bool { + switch j.Status { + case "done", "failed", "cancelled", "expired": + return true + } + return false +} + +type DeletionLog struct { + ID int64 + JobID uuid.UUID `gorm:"type:uuid"` + FileKind string + PathHash string + DeletedAt time.Time + Reason string +} + +func (DeletionLog) TableName() string { return "vod_deletion_logs" } + +var ErrConflict = errors.New("VOD job changed concurrently") + +type Store struct{ DB *gorm.DB } + +func NewStore(db *gorm.DB) *Store { + return &Store{DB: db.Session(&gorm.Session{Logger: gormlogger.Default.LogMode(gormlogger.Silent)})} +} +func (s *Store) Create(ctx context.Context, j *Job) error { + return s.DB.WithContext(ctx).Create(j).Error +} +func (s *Store) Get(ctx context.Context, id, user uuid.UUID) (Job, error) { + var j Job + err := s.DB.WithContext(ctx).Where("id = ? AND user_id = ? AND deleted_at IS NULL", id, user).Take(&j).Error + return j, err +} +func (s *Store) All(ctx context.Context) ([]Job, error) { + var jobs []Job + err := s.DB.WithContext(ctx).Order("queued_at, id").Find(&jobs).Error + return jobs, err +} + +// Version compare-and-swap keeps late worker messages from resurrecting deleted jobs. +func (s *Store) Save(ctx context.Context, j *Job) error { + old := j.Version + j.Version++ + r := s.DB.WithContext(ctx).Model(&Job{}).Where("id = ? AND version = ?", j.ID, old).Select("*").Updates(j) + if r.Error != nil { + j.Version = old + return r.Error + } + if r.RowsAffected != 1 { + j.Version = old + return ErrConflict + } + return nil +} +func (s *Store) RecordDeletion(ctx context.Context, l *DeletionLog) error { + return s.DB.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(l).Error +} From d2580cd456080a839317f146a8aab639bddf1c5a Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:47:53 +0900 Subject: [PATCH 05/23] =?UTF-8?q?feat:=20VodProcessor=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20=EC=8B=A4=EC=A0=9C=20gRPC=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/gen/aiv1/ai_processor.pb.go | 1572 ++++++++++++++++++++++---- api/gen/aiv1/ai_processor_grpc.pb.go | 320 +++++- api/proto/ai_processor.proto | 135 +++ internal/ai/vod_client.go | 6 + internal/vod/worker.go | 137 +++ 5 files changed, 1922 insertions(+), 248 deletions(-) create mode 100644 internal/ai/vod_client.go create mode 100644 internal/vod/worker.go diff --git a/api/gen/aiv1/ai_processor.pb.go b/api/gen/aiv1/ai_processor.pb.go index e8c6824..e410bc3 100644 --- a/api/gen/aiv1/ai_processor.pb.go +++ b/api/gen/aiv1/ai_processor.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.11 -// protoc v6.33.2 +// protoc-gen-go v1.36.12 +// protoc v6.33.0 // source: api/proto/ai_processor.proto package aiv1 @@ -121,6 +121,165 @@ func (VideoOutputMode) EnumDescriptor() ([]byte, []int) { return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{1} } +type VodJobState int32 + +const ( + VodJobState_VOD_JOB_STATE_UNSPECIFIED VodJobState = 0 + VodJobState_VOD_JOB_STATE_PROCESSING VodJobState = 1 + VodJobState_VOD_JOB_STATE_PAUSED VodJobState = 2 + VodJobState_VOD_JOB_STATE_DONE VodJobState = 3 + VodJobState_VOD_JOB_STATE_FAILED VodJobState = 4 + VodJobState_VOD_JOB_STATE_CANCELLED VodJobState = 5 +) + +// Enum value maps for VodJobState. +var ( + VodJobState_name = map[int32]string{ + 0: "VOD_JOB_STATE_UNSPECIFIED", + 1: "VOD_JOB_STATE_PROCESSING", + 2: "VOD_JOB_STATE_PAUSED", + 3: "VOD_JOB_STATE_DONE", + 4: "VOD_JOB_STATE_FAILED", + 5: "VOD_JOB_STATE_CANCELLED", + } + VodJobState_value = map[string]int32{ + "VOD_JOB_STATE_UNSPECIFIED": 0, + "VOD_JOB_STATE_PROCESSING": 1, + "VOD_JOB_STATE_PAUSED": 2, + "VOD_JOB_STATE_DONE": 3, + "VOD_JOB_STATE_FAILED": 4, + "VOD_JOB_STATE_CANCELLED": 5, + } +) + +func (x VodJobState) Enum() *VodJobState { + p := new(VodJobState) + *p = x + return p +} + +func (x VodJobState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (VodJobState) Descriptor() protoreflect.EnumDescriptor { + return file_api_proto_ai_processor_proto_enumTypes[2].Descriptor() +} + +func (VodJobState) Type() protoreflect.EnumType { + return &file_api_proto_ai_processor_proto_enumTypes[2] +} + +func (x VodJobState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use VodJobState.Descriptor instead. +func (VodJobState) EnumDescriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{2} +} + +type VodQuality int32 + +const ( + // Mode A: one pass, live-parity pipeline (detection→tracking→recognition→mosaic). + VodQuality_VOD_QUALITY_STANDARD VodQuality = 0 + // Mode B (high precision: lookahead/backfill, multi-pass). Rejected with + // FAILED_PRECONDITION until implemented; field reserved now so the server need not change. + VodQuality_VOD_QUALITY_HIGH VodQuality = 1 +) + +// Enum value maps for VodQuality. +var ( + VodQuality_name = map[int32]string{ + 0: "VOD_QUALITY_STANDARD", + 1: "VOD_QUALITY_HIGH", + } + VodQuality_value = map[string]int32{ + "VOD_QUALITY_STANDARD": 0, + "VOD_QUALITY_HIGH": 1, + } +) + +func (x VodQuality) Enum() *VodQuality { + p := new(VodQuality) + *p = x + return p +} + +func (x VodQuality) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (VodQuality) Descriptor() protoreflect.EnumDescriptor { + return file_api_proto_ai_processor_proto_enumTypes[3].Descriptor() +} + +func (VodQuality) Type() protoreflect.EnumType { + return &file_api_proto_ai_processor_proto_enumTypes[3] +} + +func (x VodQuality) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use VodQuality.Descriptor instead. +func (VodQuality) EnumDescriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{3} +} + +type VodPauseReason int32 + +const ( + VodPauseReason_VOD_PAUSE_REASON_UNSPECIFIED VodPauseReason = 0 + VodPauseReason_VOD_PAUSE_REASON_REQUESTED VodPauseReason = 1 // server PauseVodJob (e.g. live demand) + VodPauseReason_VOD_PAUSE_REASON_WORKER_DRAINING VodPauseReason = 2 // set_accepting(False) / rolling replace / SIGTERM + VodPauseReason_VOD_PAUSE_REASON_RUNTIME_RECOVERY VodPauseReason = 3 // worker entered runtime recovery +) + +// Enum value maps for VodPauseReason. +var ( + VodPauseReason_name = map[int32]string{ + 0: "VOD_PAUSE_REASON_UNSPECIFIED", + 1: "VOD_PAUSE_REASON_REQUESTED", + 2: "VOD_PAUSE_REASON_WORKER_DRAINING", + 3: "VOD_PAUSE_REASON_RUNTIME_RECOVERY", + } + VodPauseReason_value = map[string]int32{ + "VOD_PAUSE_REASON_UNSPECIFIED": 0, + "VOD_PAUSE_REASON_REQUESTED": 1, + "VOD_PAUSE_REASON_WORKER_DRAINING": 2, + "VOD_PAUSE_REASON_RUNTIME_RECOVERY": 3, + } +) + +func (x VodPauseReason) Enum() *VodPauseReason { + p := new(VodPauseReason) + *p = x + return p +} + +func (x VodPauseReason) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (VodPauseReason) Descriptor() protoreflect.EnumDescriptor { + return file_api_proto_ai_processor_proto_enumTypes[4].Descriptor() +} + +func (VodPauseReason) Type() protoreflect.EnumType { + return &file_api_proto_ai_processor_proto_enumTypes[4] +} + +func (x VodPauseReason) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use VodPauseReason.Descriptor instead. +func (VodPauseReason) EnumDescriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{4} +} + type VideoChunk struct { state protoimpl.MessageState `protogen:"open.v1"` // One complete JPEG frame. @@ -1859,187 +2018,1042 @@ func (x *ListSessionsResponse) GetSessions() []*SessionInfo { return nil } -var File_api_proto_ai_processor_proto protoreflect.FileDescriptor +type VodWhitelist struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Must equal ExportWhitelistResponse.embedding_model of this worker, else EMBEDDING_MODEL_MISMATCH. + EmbeddingModel string `protobuf:"bytes,1,opt,name=embedding_model,json=embeddingModel,proto3" json:"embedding_model,omitempty"` + Entries []*WhitelistEmbedding `protobuf:"bytes,2,rep,name=entries,proto3" json:"entries,omitempty"` // existing message; unit-norm AdaFace vectors + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} -const file_api_proto_ai_processor_proto_rawDesc = "" + - "\n" + - "\x1capi/proto/ai_processor.proto\x1a\x1bgoogle/protobuf/empty.proto\"\x90\x03\n" + - "\n" + - "VideoChunk\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data\x12\x1c\n" + - "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12\x19\n" + - "\bframe_id\x18\x03 \x01(\x03R\aframeId\x12!\n" + - "\n" + - "batch_size\x18\x04 \x01(\rB\x02\x18\x01R\tbatchSize\x12\x1d\n" + - "\n" + - "session_id\x18\x05 \x01(\tR\tsessionId\x121\n" + - "\voutput_mode\x18\x06 \x01(\x0e2\x10.VideoOutputModeR\n" + - "outputMode\x122\n" + - "\rmosaic_config\x18\a \x01(\v2\r.MosaicConfigR\fmosaicConfig\x12\x14\n" + - "\x05width\x18\b \x01(\rR\x05width\x12\x16\n" + - "\x06height\x18\t \x01(\rR\x06height\x12\x17\n" + - "\apix_fmt\x18\n" + - " \x01(\tR\x06pixFmt\x12E\n" + - "\x12anonymization_mode\x18\v \x01(\x0e2\x16.FaceAnonymizationModeR\x11anonymizationMode\"\xe6\x01\n" + - "\x0eFaceAttributes\x12\x16\n" + - "\x06gender\x18\x01 \x01(\tR\x06gender\x12\x10\n" + - "\x03age\x18\x02 \x01(\tR\x03age\x12\x18\n" + - "\aglasses\x18\x03 \x01(\tR\aglasses\x12\x10\n" + - "\x03exp\x18\x04 \x01(\tR\x03exp\x12?\n" + - "\n" + - "confidence\x18\x05 \x03(\v2\x1f.FaceAttributes.ConfidenceEntryR\n" + - "confidence\x1a=\n" + - "\x0fConfidenceEntry\x12\x10\n" + - "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + - "\x05value\x18\x02 \x01(\x02R\x05value:\x028\x01\"\xc7\x01\n" + - "\x11FaceAnonymization\x12/\n" + - "\n" + - "attributes\x18\x01 \x01(\v2\x0f.FaceAttributesR\n" + - "attributes\x12!\n" + - "\fidentity_key\x18\x02 \x01(\tR\videntityKey\x12\x1d\n" + - "\n" + - "preset_key\x18\x03 \x01(\tR\tpresetKey\x12\x16\n" + - "\x06status\x18\x04 \x01(\tR\x06status\x12'\n" + - "\x0ffallback_reason\x18\x05 \x01(\tR\x0efallbackReason\"\xad\x01\n" + - "\fMosaicConfig\x12$\n" + - "\vblur_radius\x18\x01 \x01(\x01H\x00R\n" + - "blurRadius\x88\x01\x01\x12\"\n" + - "\n" + - "pixel_size\x18\x02 \x01(\rH\x01R\tpixelSize\x88\x01\x01\x12$\n" + - "\vmask_margin\x18\x03 \x01(\rH\x02R\n" + - "maskMargin\x88\x01\x01B\x0e\n" + - "\f_blur_radiusB\r\n" + - "\v_pixel_sizeB\x0e\n" + - "\f_mask_margin\"\xb8\x03\n" + - "\x13ProcessedVideoChunk\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data\x12\x1c\n" + - "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12%\n" + - "\x0estatus_message\x18\x03 \x01(\tR\rstatusMessage\x12#\n" + - "\x05faces\x18\x04 \x03(\v2\r.FaceMetadataR\x05faces\x12\x14\n" + - "\x05width\x18\x05 \x01(\x05R\x05width\x12\x16\n" + - "\x06height\x18\x06 \x01(\x05R\x06height\x12\x19\n" + - "\bframe_id\x18\a \x01(\x03R\aframeId\x12#\n" + - "\rprocessing_ms\x18\b \x01(\x01R\fprocessingMs\x12)\n" + - "\x06timing\x18\t \x01(\v2\x11.ProcessingTimingR\x06timing\x12\x1d\n" + - "\n" + - "error_code\x18\n" + - " \x01(\tR\terrorCode\x12#\n" + - "\rerror_message\x18\v \x01(\tR\ferrorMessage\x12!\n" + - "\x05stats\x18\f \x01(\v2\v.FrameStatsR\x05stats\x12#\n" + - "\vmosaic_jpeg\x18\r \x01(\fB\x02\x18\x01R\n" + - "mosaicJpeg\"\xdb\x02\n" + - "\x10ProcessingTiming\x12\x19\n" + - "\bqueue_ms\x18\x01 \x01(\x01R\aqueueMs\x12\x1b\n" + - "\tdecode_ms\x18\x02 \x01(\x01R\bdecodeMs\x12!\n" + - "\finference_ms\x18\x03 \x01(\x01R\vinferenceMs\x12\x1f\n" + - "\vtracking_ms\x18\x04 \x01(\x01R\n" + - "trackingMs\x12$\n" + - "\x0eblur_encode_ms\x18\x05 \x01(\x01R\fblurEncodeMs\x120\n" + - "\x14inference_batch_size\x18\x06 \x01(\rR\x12inferenceBatchSize\x12!\n" + - "\fserialize_ms\x18\a \x01(\x01R\vserializeMs\x12&\n" + - "\x0fserver_total_ms\x18\b \x01(\x01R\rserverTotalMs\x12(\n" + - "\x10runtime_total_ms\x18\t \x01(\x01R\x0eruntimeTotalMs\"#\n" + - "\x05Point\x12\f\n" + - "\x01x\x18\x01 \x01(\x02R\x01x\x12\f\n" + - "\x01y\x18\x02 \x01(\x02R\x01y\"M\n" + - "\vBoundingBox\x12\x0e\n" + - "\x02x1\x18\x01 \x01(\x02R\x02x1\x12\x0e\n" + - "\x02y1\x18\x02 \x01(\x02R\x02y1\x12\x0e\n" + - "\x02x2\x18\x03 \x01(\x02R\x02x2\x12\x0e\n" + - "\x02y2\x18\x04 \x01(\x02R\x02y2\"\x89\x03\n" + - "\fFaceMetadata\x12 \n" + - "\x04bbox\x18\x01 \x01(\v2\f.BoundingBoxR\x04bbox\x12\x1e\n" + - "\n" + - "confidence\x18\x02 \x01(\x02R\n" + - "confidence\x12 \n" + - "\apolygon\x18\x03 \x03(\v2\x06.PointR\apolygon\x12\x1e\n" + - "\btrack_id\x18\x04 \x01(\x03H\x00R\atrackId\x88\x01\x01\x12\x16\n" + - "\x06source\x18\x05 \x01(\tR\x06source\x12\x12\n" + - "\x04held\x18\x06 \x01(\bR\x04held\x12\x1f\n" + - "\vhold_frames\x18\a \x01(\rR\n" + - "holdFrames\x12\x1d\n" + - "\n" + - "class_name\x18\b \x01(\tR\tclassName\x12 \n" + - "\fmask_area_px\x18\t \x01(\x02R\n" + - "maskAreaPx\x12 \n" + - "\vwhitelisted\x18\n" + - " \x01(\bR\vwhitelisted\x128\n" + - "\ranonymization\x18\v \x01(\v2\x12.FaceAnonymizationR\ranonymizationB\v\n" + - "\t_track_id\"\xec\x03\n" + - "\n" + - "FrameStats\x12\x1e\n" + - "\n" + - "detections\x18\x01 \x01(\rR\n" + - "detections\x12%\n" + - "\x0eraw_detections\x18\x02 \x01(\rR\rrawDetections\x127\n" + - "\x17continuation_candidates\x18\x03 \x01(\rR\x16continuationCandidates\x124\n" + - "\x16detector_backed_tracks\x18\x04 \x01(\rR\x14detectorBackedTracks\x12@\n" + - "\x1clow_confidence_continuations\x18\x05 \x01(\rR\x1alowConfidenceContinuations\x12\x1f\n" + - "\vheld_tracks\x18\x06 \x01(\rR\n" + - "heldTracks\x12\x16\n" + - "\x06tracks\x18\a \x01(\rR\x06tracks\x12#\n" + - "\rtracker_frame\x18\b \x01(\x04R\ftrackerFrame\x12#\n" + - "\radaface_calls\x18\t \x01(\rR\fadafaceCalls\x124\n" + - "\x16adaface_queue_overflow\x18\n" + - " \x01(\rR\x14adafaceQueueOverflow\x12-\n" + - "\x12whitelisted_tracks\x18\v \x01(\rR\x11whitelistedTracks\"=\n" + - "\bFaceData\x12\x12\n" + - "\x04data\x18\x01 \x01(\fR\x04data\x12\x1d\n" + - "\n" + - "session_id\x18\x02 \x01(\tR\tsessionId\"\xc1\x01\n" + - "\x11WhitelistResponse\x12%\n" + - "\x0estatus_message\x18\x01 \x01(\tR\rstatusMessage\x12\x1c\n" + - "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12\x19\n" + - "\bentry_id\x18\x03 \x01(\tR\aentryId\x12\x1f\n" + - "\ventry_count\x18\x04 \x01(\rR\n" + - "entryCount\x12+\n" + - "\x11whitelist_version\x18\x05 \x01(\x04R\x10whitelistVersion\":\n" + - "\x19GetWhitelistStatusRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"\xa6\x01\n" + - "\x1aGetWhitelistStatusResponse\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1f\n" + - "\ventry_count\x18\x02 \x01(\rR\n" + - "entryCount\x12+\n" + - "\x11whitelist_version\x18\x03 \x01(\x04R\x10whitelistVersion\x12\x1b\n" + - "\tentry_ids\x18\x04 \x03(\tR\bentryIds\"R\n" + - "\x16DeleteWhitelistRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x19\n" + - "\bentry_id\x18\x02 \x01(\tR\aentryId\"G\n" + - "\x12WhitelistEmbedding\x12\x19\n" + - "\bentry_id\x18\x01 \x01(\tR\aentryId\x12\x16\n" + - "\x06values\x18\x02 \x03(\x02R\x06values\"7\n" + - "\x16ExportWhitelistRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"\xbd\x01\n" + - "\x17ExportWhitelistResponse\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12'\n" + - "\x0fembedding_model\x18\x02 \x01(\tR\x0eembeddingModel\x12-\n" + - "\aentries\x18\x03 \x03(\v2\x13.WhitelistEmbeddingR\aentries\x12+\n" + - "\x11whitelist_version\x18\x04 \x01(\x04R\x10whitelistVersion\"\x8f\x01\n" + - "\x16ImportWhitelistRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12'\n" + - "\x0fembedding_model\x18\x02 \x01(\tR\x0eembeddingModel\x12-\n" + - "\aentries\x18\x03 \x03(\v2\x13.WhitelistEmbeddingR\aentries\"\x16\n" + - "\x14CreateSessionRequest\"\x15\n" + - "\x13ListSessionsRequest\"5\n" + - "\x14DeleteSessionRequest\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\"\xd7\x01\n" + - "\vSessionInfo\x12\x1d\n" + - "\n" + - "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1f\n" + - "\ventry_count\x18\x02 \x01(\rR\n" + - "entryCount\x12+\n" + - "\x11whitelist_version\x18\x03 \x01(\x04R\x10whitelistVersion\x12+\n" + +func (x *VodWhitelist) Reset() { + *x = VodWhitelist{} + mi := &file_api_proto_ai_processor_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VodWhitelist) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VodWhitelist) ProtoMessage() {} + +func (x *VodWhitelist) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VodWhitelist.ProtoReflect.Descriptor instead. +func (*VodWhitelist) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{24} +} + +func (x *VodWhitelist) GetEmbeddingModel() string { + if x != nil { + return x.EmbeddingModel + } + return "" +} + +func (x *VodWhitelist) GetEntries() []*WhitelistEmbedding { + if x != nil { + return x.Entries + } + return nil +} + +type VodOutputOptions struct { + state protoimpl.MessageState `protogen:"open.v1"` + // "auto" (h264_nvenc when available, else libx264), "h264_nvenc", "libx264". + VideoEncoder string `protobuf:"bytes,1,opt,name=video_encoder,json=videoEncoder,proto3" json:"video_encoder,omitempty"` + // Constant-quality target: NVENC -cq / x264 -crf. 0 = default (20). + Quality uint32 `protobuf:"varint,2,opt,name=quality,proto3" json:"quality,omitempty"` + // Re-encode audio to AAC even when stream copy is possible. + ForceAudioAac bool `protobuf:"varint,3,opt,name=force_audio_aac,json=forceAudioAac,proto3" json:"force_audio_aac,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VodOutputOptions) Reset() { + *x = VodOutputOptions{} + mi := &file_api_proto_ai_processor_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VodOutputOptions) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VodOutputOptions) ProtoMessage() {} + +func (x *VodOutputOptions) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VodOutputOptions.ProtoReflect.Descriptor instead. +func (*VodOutputOptions) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{25} +} + +func (x *VodOutputOptions) GetVideoEncoder() string { + if x != nil { + return x.VideoEncoder + } + return "" +} + +func (x *VodOutputOptions) GetQuality() uint32 { + if x != nil { + return x.Quality + } + return 0 +} + +func (x *VodOutputOptions) GetForceAudioAac() bool { + if x != nil { + return x.ForceAudioAac + } + return false +} + +type StartVodJobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Server-generated stable ID (UUID). Same ID across pause/resume and across workers. + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + // Absolute paths under the worker's --vod-root (shared local disk). Symlinks resolved; outside root → rejected. + InputPath string `protobuf:"bytes,2,opt,name=input_path,json=inputPath,proto3" json:"input_path,omitempty"` + OutputPath string `protobuf:"bytes,3,opt,name=output_path,json=outputPath,proto3" json:"output_path,omitempty"` // final file, written as .part then renamed atomically on DONE + WorkDir string `protobuf:"bytes,4,opt,name=work_dir,json=workDir,proto3" json:"work_dir,omitempty"` // checkpoint.json + segments/; created if absent + // Registered-person exclusion. Empty entries = protect everyone. + // + // Types that are valid to be assigned to WhitelistSource: + // + // *StartVodJobRequest_Whitelist + // *StartVodJobRequest_WhitelistSessionId + WhitelistSource isStartVodJobRequest_WhitelistSource `protobuf_oneof:"whitelist_source"` + Quality VodQuality `protobuf:"varint,7,opt,name=quality,proto3,enum=VodQuality" json:"quality,omitempty"` + MosaicConfig *MosaicConfig `protobuf:"bytes,8,opt,name=mosaic_config,json=mosaicConfig,proto3" json:"mosaic_config,omitempty"` // existing message; fixed for the job + AnonymizationMode FaceAnonymizationMode `protobuf:"varint,9,opt,name=anonymization_mode,json=anonymizationMode,proto3,enum=FaceAnonymizationMode" json:"anonymization_mode,omitempty"` // v1 accepts BLUR only; others → FAILED_PRECONDITION + Output *VodOutputOptions `protobuf:"bytes,10,opt,name=output,proto3" json:"output,omitempty"` + // false = new job (work_dir must have no checkpoint, output must not exist unless overwrite_output). + // true = resume from work_dir/checkpoint.json; checkpoint_token must match. + Resume bool `protobuf:"varint,11,opt,name=resume,proto3" json:"resume,omitempty"` + CheckpointToken string `protobuf:"bytes,12,opt,name=checkpoint_token,json=checkpointToken,proto3" json:"checkpoint_token,omitempty"` + OverwriteOutput bool `protobuf:"varint,13,opt,name=overwrite_output,json=overwriteOutput,proto3" json:"overwrite_output,omitempty"` + // Segment length in source frames at which checkpoints are cut. 0 = default 300. + SegmentFrames uint32 `protobuf:"varint,14,opt,name=segment_frames,json=segmentFrames,proto3" json:"segment_frames,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *StartVodJobRequest) Reset() { + *x = StartVodJobRequest{} + mi := &file_api_proto_ai_processor_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *StartVodJobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StartVodJobRequest) ProtoMessage() {} + +func (x *StartVodJobRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StartVodJobRequest.ProtoReflect.Descriptor instead. +func (*StartVodJobRequest) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{26} +} + +func (x *StartVodJobRequest) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *StartVodJobRequest) GetInputPath() string { + if x != nil { + return x.InputPath + } + return "" +} + +func (x *StartVodJobRequest) GetOutputPath() string { + if x != nil { + return x.OutputPath + } + return "" +} + +func (x *StartVodJobRequest) GetWorkDir() string { + if x != nil { + return x.WorkDir + } + return "" +} + +func (x *StartVodJobRequest) GetWhitelistSource() isStartVodJobRequest_WhitelistSource { + if x != nil { + return x.WhitelistSource + } + return nil +} + +func (x *StartVodJobRequest) GetWhitelist() *VodWhitelist { + if x != nil { + if x, ok := x.WhitelistSource.(*StartVodJobRequest_Whitelist); ok { + return x.Whitelist + } + } + return nil +} + +func (x *StartVodJobRequest) GetWhitelistSessionId() string { + if x != nil { + if x, ok := x.WhitelistSource.(*StartVodJobRequest_WhitelistSessionId); ok { + return x.WhitelistSessionId + } + } + return "" +} + +func (x *StartVodJobRequest) GetQuality() VodQuality { + if x != nil { + return x.Quality + } + return VodQuality_VOD_QUALITY_STANDARD +} + +func (x *StartVodJobRequest) GetMosaicConfig() *MosaicConfig { + if x != nil { + return x.MosaicConfig + } + return nil +} + +func (x *StartVodJobRequest) GetAnonymizationMode() FaceAnonymizationMode { + if x != nil { + return x.AnonymizationMode + } + return FaceAnonymizationMode_FACE_ANONYMIZATION_MODE_BLUR +} + +func (x *StartVodJobRequest) GetOutput() *VodOutputOptions { + if x != nil { + return x.Output + } + return nil +} + +func (x *StartVodJobRequest) GetResume() bool { + if x != nil { + return x.Resume + } + return false +} + +func (x *StartVodJobRequest) GetCheckpointToken() string { + if x != nil { + return x.CheckpointToken + } + return "" +} + +func (x *StartVodJobRequest) GetOverwriteOutput() bool { + if x != nil { + return x.OverwriteOutput + } + return false +} + +func (x *StartVodJobRequest) GetSegmentFrames() uint32 { + if x != nil { + return x.SegmentFrames + } + return 0 +} + +type isStartVodJobRequest_WhitelistSource interface { + isStartVodJobRequest_WhitelistSource() +} + +type StartVodJobRequest_Whitelist struct { + Whitelist *VodWhitelist `protobuf:"bytes,5,opt,name=whitelist,proto3,oneof"` // preferred: server ExportWhitelist()s the user's session from any worker +} + +type StartVodJobRequest_WhitelistSessionId struct { + WhitelistSessionId string `protobuf:"bytes,6,opt,name=whitelist_session_id,json=whitelistSessionId,proto3,oneof"` // convenience: a session already on THIS worker; snapshot taken at start +} + +func (*StartVodJobRequest_Whitelist) isStartVodJobRequest_WhitelistSource() {} + +func (*StartVodJobRequest_WhitelistSessionId) isStartVodJobRequest_WhitelistSource() {} + +type VodJobRef struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VodJobRef) Reset() { + *x = VodJobRef{} + mi := &file_api_proto_ai_processor_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VodJobRef) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VodJobRef) ProtoMessage() {} + +func (x *VodJobRef) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VodJobRef.ProtoReflect.Descriptor instead. +func (*VodJobRef) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{27} +} + +func (x *VodJobRef) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +type PauseVodJobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PauseVodJobRequest) Reset() { + *x = PauseVodJobRequest{} + mi := &file_api_proto_ai_processor_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PauseVodJobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PauseVodJobRequest) ProtoMessage() {} + +func (x *PauseVodJobRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PauseVodJobRequest.ProtoReflect.Descriptor instead. +func (*PauseVodJobRequest) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{28} +} + +func (x *PauseVodJobRequest) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +type CancelVodJobRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + PurgeWorkDir bool `protobuf:"varint,2,opt,name=purge_work_dir,json=purgeWorkDir,proto3" json:"purge_work_dir,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CancelVodJobRequest) Reset() { + *x = CancelVodJobRequest{} + mi := &file_api_proto_ai_processor_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CancelVodJobRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelVodJobRequest) ProtoMessage() {} + +func (x *CancelVodJobRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelVodJobRequest.ProtoReflect.Descriptor instead. +func (*CancelVodJobRequest) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{29} +} + +func (x *CancelVodJobRequest) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *CancelVodJobRequest) GetPurgeWorkDir() bool { + if x != nil { + return x.PurgeWorkDir + } + return false +} + +type ListVodJobsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListVodJobsRequest) Reset() { + *x = ListVodJobsRequest{} + mi := &file_api_proto_ai_processor_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListVodJobsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListVodJobsRequest) ProtoMessage() {} + +func (x *ListVodJobsRequest) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListVodJobsRequest.ProtoReflect.Descriptor instead. +func (*ListVodJobsRequest) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{30} +} + +type ListVodJobsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Jobs []*VodJobStatus `protobuf:"bytes,1,rep,name=jobs,proto3" json:"jobs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListVodJobsResponse) Reset() { + *x = ListVodJobsResponse{} + mi := &file_api_proto_ai_processor_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListVodJobsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListVodJobsResponse) ProtoMessage() {} + +func (x *ListVodJobsResponse) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListVodJobsResponse.ProtoReflect.Descriptor instead. +func (*ListVodJobsResponse) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{31} +} + +func (x *ListVodJobsResponse) GetJobs() []*VodJobStatus { + if x != nil { + return x.Jobs + } + return nil +} + +type VodProgress struct { + state protoimpl.MessageState `protogen:"open.v1"` + FramesDone uint64 `protobuf:"varint,1,opt,name=frames_done,json=framesDone,proto3" json:"frames_done,omitempty"` // source frames fully processed and committed or in the current segment + FramesTotal uint64 `protobuf:"varint,2,opt,name=frames_total,json=framesTotal,proto3" json:"frames_total,omitempty"` // from ffprobe nb_frames, else round(duration × avg_frame_rate) + FramesTotalEstimated bool `protobuf:"varint,3,opt,name=frames_total_estimated,json=framesTotalEstimated,proto3" json:"frames_total_estimated,omitempty"` // true when nb_frames was absent + Fraction float64 `protobuf:"fixed64,4,opt,name=fraction,proto3" json:"fraction,omitempty"` // frames_done / frames_total, clamped to [0,1]; 1.0 only at DONE + SegmentsDone uint32 `protobuf:"varint,5,opt,name=segments_done,json=segmentsDone,proto3" json:"segments_done,omitempty"` + SegmentsTotal uint32 `protobuf:"varint,6,opt,name=segments_total,json=segmentsTotal,proto3" json:"segments_total,omitempty"` + ProcessingFps float64 `protobuf:"fixed64,7,opt,name=processing_fps,json=processingFps,proto3" json:"processing_fps,omitempty"` // EWMA over the last ~10 s of active processing + EtaSeconds float64 `protobuf:"fixed64,8,opt,name=eta_seconds,json=etaSeconds,proto3" json:"eta_seconds,omitempty"` // (frames_total-frames_done)/processing_fps; -1 if unknown + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VodProgress) Reset() { + *x = VodProgress{} + mi := &file_api_proto_ai_processor_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VodProgress) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VodProgress) ProtoMessage() {} + +func (x *VodProgress) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VodProgress.ProtoReflect.Descriptor instead. +func (*VodProgress) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{32} +} + +func (x *VodProgress) GetFramesDone() uint64 { + if x != nil { + return x.FramesDone + } + return 0 +} + +func (x *VodProgress) GetFramesTotal() uint64 { + if x != nil { + return x.FramesTotal + } + return 0 +} + +func (x *VodProgress) GetFramesTotalEstimated() bool { + if x != nil { + return x.FramesTotalEstimated + } + return false +} + +func (x *VodProgress) GetFraction() float64 { + if x != nil { + return x.Fraction + } + return 0 +} + +func (x *VodProgress) GetSegmentsDone() uint32 { + if x != nil { + return x.SegmentsDone + } + return 0 +} + +func (x *VodProgress) GetSegmentsTotal() uint32 { + if x != nil { + return x.SegmentsTotal + } + return 0 +} + +func (x *VodProgress) GetProcessingFps() float64 { + if x != nil { + return x.ProcessingFps + } + return 0 +} + +func (x *VodProgress) GetEtaSeconds() float64 { + if x != nil { + return x.EtaSeconds + } + return 0 +} + +type VodJobStatus struct { + state protoimpl.MessageState `protogen:"open.v1"` + JobId string `protobuf:"bytes,1,opt,name=job_id,json=jobId,proto3" json:"job_id,omitempty"` + State VodJobState `protobuf:"varint,2,opt,name=state,proto3,enum=VodJobState" json:"state,omitempty"` + Progress *VodProgress `protobuf:"bytes,3,opt,name=progress,proto3" json:"progress,omitempty"` + // Opaque token naming the last durable checkpoint (empty before the first segment commits). + // Pass back in StartVodJobRequest.checkpoint_token to resume. + CheckpointToken string `protobuf:"bytes,4,opt,name=checkpoint_token,json=checkpointToken,proto3" json:"checkpoint_token,omitempty"` + PauseReason VodPauseReason `protobuf:"varint,5,opt,name=pause_reason,json=pauseReason,proto3,enum=VodPauseReason" json:"pause_reason,omitempty"` + ErrorCode string `protobuf:"bytes,6,opt,name=error_code,json=errorCode,proto3" json:"error_code,omitempty"` // §6, set for FAILED (and CANCELLED = "CANCELLED") + ErrorMessage string `protobuf:"bytes,7,opt,name=error_message,json=errorMessage,proto3" json:"error_message,omitempty"` + OutputPath string `protobuf:"bytes,8,opt,name=output_path,json=outputPath,proto3" json:"output_path,omitempty"` // set at DONE + UpdatedAtUnixMs int64 `protobuf:"varint,9,opt,name=updated_at_unix_ms,json=updatedAtUnixMs,proto3" json:"updated_at_unix_ms,omitempty"` + // Source facts from probe (set from admission onward). + Width uint32 `protobuf:"varint,10,opt,name=width,proto3" json:"width,omitempty"` + Height uint32 `protobuf:"varint,11,opt,name=height,proto3" json:"height,omitempty"` + AvgFrameRate float64 `protobuf:"fixed64,12,opt,name=avg_frame_rate,json=avgFrameRate,proto3" json:"avg_frame_rate,omitempty"` + DurationSeconds float64 `protobuf:"fixed64,13,opt,name=duration_seconds,json=durationSeconds,proto3" json:"duration_seconds,omitempty"` + HasAudio bool `protobuf:"varint,14,opt,name=has_audio,json=hasAudio,proto3" json:"has_audio,omitempty"` + InputSizeBytes uint64 `protobuf:"varint,16,opt,name=input_size_bytes,json=inputSizeBytes,proto3" json:"input_size_bytes,omitempty"` // stat size at admission, checked against --vod-max-input-bytes + WorkerTarget string `protobuf:"bytes,15,opt,name=worker_target,json=workerTarget,proto3" json:"worker_target,omitempty"` // host:port this worker listens on, for server bookkeeping + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *VodJobStatus) Reset() { + *x = VodJobStatus{} + mi := &file_api_proto_ai_processor_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *VodJobStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*VodJobStatus) ProtoMessage() {} + +func (x *VodJobStatus) ProtoReflect() protoreflect.Message { + mi := &file_api_proto_ai_processor_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use VodJobStatus.ProtoReflect.Descriptor instead. +func (*VodJobStatus) Descriptor() ([]byte, []int) { + return file_api_proto_ai_processor_proto_rawDescGZIP(), []int{33} +} + +func (x *VodJobStatus) GetJobId() string { + if x != nil { + return x.JobId + } + return "" +} + +func (x *VodJobStatus) GetState() VodJobState { + if x != nil { + return x.State + } + return VodJobState_VOD_JOB_STATE_UNSPECIFIED +} + +func (x *VodJobStatus) GetProgress() *VodProgress { + if x != nil { + return x.Progress + } + return nil +} + +func (x *VodJobStatus) GetCheckpointToken() string { + if x != nil { + return x.CheckpointToken + } + return "" +} + +func (x *VodJobStatus) GetPauseReason() VodPauseReason { + if x != nil { + return x.PauseReason + } + return VodPauseReason_VOD_PAUSE_REASON_UNSPECIFIED +} + +func (x *VodJobStatus) GetErrorCode() string { + if x != nil { + return x.ErrorCode + } + return "" +} + +func (x *VodJobStatus) GetErrorMessage() string { + if x != nil { + return x.ErrorMessage + } + return "" +} + +func (x *VodJobStatus) GetOutputPath() string { + if x != nil { + return x.OutputPath + } + return "" +} + +func (x *VodJobStatus) GetUpdatedAtUnixMs() int64 { + if x != nil { + return x.UpdatedAtUnixMs + } + return 0 +} + +func (x *VodJobStatus) GetWidth() uint32 { + if x != nil { + return x.Width + } + return 0 +} + +func (x *VodJobStatus) GetHeight() uint32 { + if x != nil { + return x.Height + } + return 0 +} + +func (x *VodJobStatus) GetAvgFrameRate() float64 { + if x != nil { + return x.AvgFrameRate + } + return 0 +} + +func (x *VodJobStatus) GetDurationSeconds() float64 { + if x != nil { + return x.DurationSeconds + } + return 0 +} + +func (x *VodJobStatus) GetHasAudio() bool { + if x != nil { + return x.HasAudio + } + return false +} + +func (x *VodJobStatus) GetInputSizeBytes() uint64 { + if x != nil { + return x.InputSizeBytes + } + return 0 +} + +func (x *VodJobStatus) GetWorkerTarget() string { + if x != nil { + return x.WorkerTarget + } + return "" +} + +var File_api_proto_ai_processor_proto protoreflect.FileDescriptor + +const file_api_proto_ai_processor_proto_rawDesc = "" + + "\n" + + "\x1capi/proto/ai_processor.proto\x1a\x1bgoogle/protobuf/empty.proto\"\x90\x03\n" + + "\n" + + "VideoChunk\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12\x19\n" + + "\bframe_id\x18\x03 \x01(\x03R\aframeId\x12!\n" + + "\n" + + "batch_size\x18\x04 \x01(\rB\x02\x18\x01R\tbatchSize\x12\x1d\n" + + "\n" + + "session_id\x18\x05 \x01(\tR\tsessionId\x121\n" + + "\voutput_mode\x18\x06 \x01(\x0e2\x10.VideoOutputModeR\n" + + "outputMode\x122\n" + + "\rmosaic_config\x18\a \x01(\v2\r.MosaicConfigR\fmosaicConfig\x12\x14\n" + + "\x05width\x18\b \x01(\rR\x05width\x12\x16\n" + + "\x06height\x18\t \x01(\rR\x06height\x12\x17\n" + + "\apix_fmt\x18\n" + + " \x01(\tR\x06pixFmt\x12E\n" + + "\x12anonymization_mode\x18\v \x01(\x0e2\x16.FaceAnonymizationModeR\x11anonymizationMode\"\xe6\x01\n" + + "\x0eFaceAttributes\x12\x16\n" + + "\x06gender\x18\x01 \x01(\tR\x06gender\x12\x10\n" + + "\x03age\x18\x02 \x01(\tR\x03age\x12\x18\n" + + "\aglasses\x18\x03 \x01(\tR\aglasses\x12\x10\n" + + "\x03exp\x18\x04 \x01(\tR\x03exp\x12?\n" + + "\n" + + "confidence\x18\x05 \x03(\v2\x1f.FaceAttributes.ConfidenceEntryR\n" + + "confidence\x1a=\n" + + "\x0fConfidenceEntry\x12\x10\n" + + "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + + "\x05value\x18\x02 \x01(\x02R\x05value:\x028\x01\"\xc7\x01\n" + + "\x11FaceAnonymization\x12/\n" + + "\n" + + "attributes\x18\x01 \x01(\v2\x0f.FaceAttributesR\n" + + "attributes\x12!\n" + + "\fidentity_key\x18\x02 \x01(\tR\videntityKey\x12\x1d\n" + + "\n" + + "preset_key\x18\x03 \x01(\tR\tpresetKey\x12\x16\n" + + "\x06status\x18\x04 \x01(\tR\x06status\x12'\n" + + "\x0ffallback_reason\x18\x05 \x01(\tR\x0efallbackReason\"\xad\x01\n" + + "\fMosaicConfig\x12$\n" + + "\vblur_radius\x18\x01 \x01(\x01H\x00R\n" + + "blurRadius\x88\x01\x01\x12\"\n" + + "\n" + + "pixel_size\x18\x02 \x01(\rH\x01R\tpixelSize\x88\x01\x01\x12$\n" + + "\vmask_margin\x18\x03 \x01(\rH\x02R\n" + + "maskMargin\x88\x01\x01B\x0e\n" + + "\f_blur_radiusB\r\n" + + "\v_pixel_sizeB\x0e\n" + + "\f_mask_margin\"\xb8\x03\n" + + "\x13ProcessedVideoChunk\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12%\n" + + "\x0estatus_message\x18\x03 \x01(\tR\rstatusMessage\x12#\n" + + "\x05faces\x18\x04 \x03(\v2\r.FaceMetadataR\x05faces\x12\x14\n" + + "\x05width\x18\x05 \x01(\x05R\x05width\x12\x16\n" + + "\x06height\x18\x06 \x01(\x05R\x06height\x12\x19\n" + + "\bframe_id\x18\a \x01(\x03R\aframeId\x12#\n" + + "\rprocessing_ms\x18\b \x01(\x01R\fprocessingMs\x12)\n" + + "\x06timing\x18\t \x01(\v2\x11.ProcessingTimingR\x06timing\x12\x1d\n" + + "\n" + + "error_code\x18\n" + + " \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\v \x01(\tR\ferrorMessage\x12!\n" + + "\x05stats\x18\f \x01(\v2\v.FrameStatsR\x05stats\x12#\n" + + "\vmosaic_jpeg\x18\r \x01(\fB\x02\x18\x01R\n" + + "mosaicJpeg\"\xdb\x02\n" + + "\x10ProcessingTiming\x12\x19\n" + + "\bqueue_ms\x18\x01 \x01(\x01R\aqueueMs\x12\x1b\n" + + "\tdecode_ms\x18\x02 \x01(\x01R\bdecodeMs\x12!\n" + + "\finference_ms\x18\x03 \x01(\x01R\vinferenceMs\x12\x1f\n" + + "\vtracking_ms\x18\x04 \x01(\x01R\n" + + "trackingMs\x12$\n" + + "\x0eblur_encode_ms\x18\x05 \x01(\x01R\fblurEncodeMs\x120\n" + + "\x14inference_batch_size\x18\x06 \x01(\rR\x12inferenceBatchSize\x12!\n" + + "\fserialize_ms\x18\a \x01(\x01R\vserializeMs\x12&\n" + + "\x0fserver_total_ms\x18\b \x01(\x01R\rserverTotalMs\x12(\n" + + "\x10runtime_total_ms\x18\t \x01(\x01R\x0eruntimeTotalMs\"#\n" + + "\x05Point\x12\f\n" + + "\x01x\x18\x01 \x01(\x02R\x01x\x12\f\n" + + "\x01y\x18\x02 \x01(\x02R\x01y\"M\n" + + "\vBoundingBox\x12\x0e\n" + + "\x02x1\x18\x01 \x01(\x02R\x02x1\x12\x0e\n" + + "\x02y1\x18\x02 \x01(\x02R\x02y1\x12\x0e\n" + + "\x02x2\x18\x03 \x01(\x02R\x02x2\x12\x0e\n" + + "\x02y2\x18\x04 \x01(\x02R\x02y2\"\x89\x03\n" + + "\fFaceMetadata\x12 \n" + + "\x04bbox\x18\x01 \x01(\v2\f.BoundingBoxR\x04bbox\x12\x1e\n" + + "\n" + + "confidence\x18\x02 \x01(\x02R\n" + + "confidence\x12 \n" + + "\apolygon\x18\x03 \x03(\v2\x06.PointR\apolygon\x12\x1e\n" + + "\btrack_id\x18\x04 \x01(\x03H\x00R\atrackId\x88\x01\x01\x12\x16\n" + + "\x06source\x18\x05 \x01(\tR\x06source\x12\x12\n" + + "\x04held\x18\x06 \x01(\bR\x04held\x12\x1f\n" + + "\vhold_frames\x18\a \x01(\rR\n" + + "holdFrames\x12\x1d\n" + + "\n" + + "class_name\x18\b \x01(\tR\tclassName\x12 \n" + + "\fmask_area_px\x18\t \x01(\x02R\n" + + "maskAreaPx\x12 \n" + + "\vwhitelisted\x18\n" + + " \x01(\bR\vwhitelisted\x128\n" + + "\ranonymization\x18\v \x01(\v2\x12.FaceAnonymizationR\ranonymizationB\v\n" + + "\t_track_id\"\xec\x03\n" + + "\n" + + "FrameStats\x12\x1e\n" + + "\n" + + "detections\x18\x01 \x01(\rR\n" + + "detections\x12%\n" + + "\x0eraw_detections\x18\x02 \x01(\rR\rrawDetections\x127\n" + + "\x17continuation_candidates\x18\x03 \x01(\rR\x16continuationCandidates\x124\n" + + "\x16detector_backed_tracks\x18\x04 \x01(\rR\x14detectorBackedTracks\x12@\n" + + "\x1clow_confidence_continuations\x18\x05 \x01(\rR\x1alowConfidenceContinuations\x12\x1f\n" + + "\vheld_tracks\x18\x06 \x01(\rR\n" + + "heldTracks\x12\x16\n" + + "\x06tracks\x18\a \x01(\rR\x06tracks\x12#\n" + + "\rtracker_frame\x18\b \x01(\x04R\ftrackerFrame\x12#\n" + + "\radaface_calls\x18\t \x01(\rR\fadafaceCalls\x124\n" + + "\x16adaface_queue_overflow\x18\n" + + " \x01(\rR\x14adafaceQueueOverflow\x12-\n" + + "\x12whitelisted_tracks\x18\v \x01(\rR\x11whitelistedTracks\"=\n" + + "\bFaceData\x12\x12\n" + + "\x04data\x18\x01 \x01(\fR\x04data\x12\x1d\n" + + "\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\"\xc1\x01\n" + + "\x11WhitelistResponse\x12%\n" + + "\x0estatus_message\x18\x01 \x01(\tR\rstatusMessage\x12\x1c\n" + + "\ttimestamp\x18\x02 \x01(\x03R\ttimestamp\x12\x19\n" + + "\bentry_id\x18\x03 \x01(\tR\aentryId\x12\x1f\n" + + "\ventry_count\x18\x04 \x01(\rR\n" + + "entryCount\x12+\n" + + "\x11whitelist_version\x18\x05 \x01(\x04R\x10whitelistVersion\":\n" + + "\x19GetWhitelistStatusRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xa6\x01\n" + + "\x1aGetWhitelistStatusResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1f\n" + + "\ventry_count\x18\x02 \x01(\rR\n" + + "entryCount\x12+\n" + + "\x11whitelist_version\x18\x03 \x01(\x04R\x10whitelistVersion\x12\x1b\n" + + "\tentry_ids\x18\x04 \x03(\tR\bentryIds\"R\n" + + "\x16DeleteWhitelistRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x19\n" + + "\bentry_id\x18\x02 \x01(\tR\aentryId\"G\n" + + "\x12WhitelistEmbedding\x12\x19\n" + + "\bentry_id\x18\x01 \x01(\tR\aentryId\x12\x16\n" + + "\x06values\x18\x02 \x03(\x02R\x06values\"7\n" + + "\x16ExportWhitelistRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xbd\x01\n" + + "\x17ExportWhitelistResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12'\n" + + "\x0fembedding_model\x18\x02 \x01(\tR\x0eembeddingModel\x12-\n" + + "\aentries\x18\x03 \x03(\v2\x13.WhitelistEmbeddingR\aentries\x12+\n" + + "\x11whitelist_version\x18\x04 \x01(\x04R\x10whitelistVersion\"\x8f\x01\n" + + "\x16ImportWhitelistRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12'\n" + + "\x0fembedding_model\x18\x02 \x01(\tR\x0eembeddingModel\x12-\n" + + "\aentries\x18\x03 \x03(\v2\x13.WhitelistEmbeddingR\aentries\"\x16\n" + + "\x14CreateSessionRequest\"\x15\n" + + "\x13ListSessionsRequest\"5\n" + + "\x14DeleteSessionRequest\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\"\xd7\x01\n" + + "\vSessionInfo\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x1f\n" + + "\ventry_count\x18\x02 \x01(\rR\n" + + "entryCount\x12+\n" + + "\x11whitelist_version\x18\x03 \x01(\x04R\x10whitelistVersion\x12+\n" + "\x12created_at_unix_ms\x18\x04 \x01(\x03R\x0fcreatedAtUnixMs\x12.\n" + "\x13active_stream_count\x18\x05 \x01(\rR\x11activeStreamCount\"@\n" + "\x14ListSessionsResponse\x12(\n" + - "\bsessions\x18\x01 \x03(\v2\f.SessionInfoR\bsessions*\x8b\x01\n" + + "\bsessions\x18\x01 \x03(\v2\f.SessionInfoR\bsessions\"f\n" + + "\fVodWhitelist\x12'\n" + + "\x0fembedding_model\x18\x01 \x01(\tR\x0eembeddingModel\x12-\n" + + "\aentries\x18\x02 \x03(\v2\x13.WhitelistEmbeddingR\aentries\"y\n" + + "\x10VodOutputOptions\x12#\n" + + "\rvideo_encoder\x18\x01 \x01(\tR\fvideoEncoder\x12\x18\n" + + "\aquality\x18\x02 \x01(\rR\aquality\x12&\n" + + "\x0fforce_audio_aac\x18\x03 \x01(\bR\rforceAudioAac\"\xdf\x04\n" + + "\x12StartVodJobRequest\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\x1d\n" + + "\n" + + "input_path\x18\x02 \x01(\tR\tinputPath\x12\x1f\n" + + "\voutput_path\x18\x03 \x01(\tR\n" + + "outputPath\x12\x19\n" + + "\bwork_dir\x18\x04 \x01(\tR\aworkDir\x12-\n" + + "\twhitelist\x18\x05 \x01(\v2\r.VodWhitelistH\x00R\twhitelist\x122\n" + + "\x14whitelist_session_id\x18\x06 \x01(\tH\x00R\x12whitelistSessionId\x12%\n" + + "\aquality\x18\a \x01(\x0e2\v.VodQualityR\aquality\x122\n" + + "\rmosaic_config\x18\b \x01(\v2\r.MosaicConfigR\fmosaicConfig\x12E\n" + + "\x12anonymization_mode\x18\t \x01(\x0e2\x16.FaceAnonymizationModeR\x11anonymizationMode\x12)\n" + + "\x06output\x18\n" + + " \x01(\v2\x11.VodOutputOptionsR\x06output\x12\x16\n" + + "\x06resume\x18\v \x01(\bR\x06resume\x12)\n" + + "\x10checkpoint_token\x18\f \x01(\tR\x0fcheckpointToken\x12)\n" + + "\x10overwrite_output\x18\r \x01(\bR\x0foverwriteOutput\x12%\n" + + "\x0esegment_frames\x18\x0e \x01(\rR\rsegmentFramesB\x12\n" + + "\x10whitelist_source\"\"\n" + + "\tVodJobRef\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\"+\n" + + "\x12PauseVodJobRequest\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\"R\n" + + "\x13CancelVodJobRequest\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12$\n" + + "\x0epurge_work_dir\x18\x02 \x01(\bR\fpurgeWorkDir\"\x14\n" + + "\x12ListVodJobsRequest\"8\n" + + "\x13ListVodJobsResponse\x12!\n" + + "\x04jobs\x18\x01 \x03(\v2\r.VodJobStatusR\x04jobs\"\xb7\x02\n" + + "\vVodProgress\x12\x1f\n" + + "\vframes_done\x18\x01 \x01(\x04R\n" + + "framesDone\x12!\n" + + "\fframes_total\x18\x02 \x01(\x04R\vframesTotal\x124\n" + + "\x16frames_total_estimated\x18\x03 \x01(\bR\x14framesTotalEstimated\x12\x1a\n" + + "\bfraction\x18\x04 \x01(\x01R\bfraction\x12#\n" + + "\rsegments_done\x18\x05 \x01(\rR\fsegmentsDone\x12%\n" + + "\x0esegments_total\x18\x06 \x01(\rR\rsegmentsTotal\x12%\n" + + "\x0eprocessing_fps\x18\a \x01(\x01R\rprocessingFps\x12\x1f\n" + + "\veta_seconds\x18\b \x01(\x01R\n" + + "etaSeconds\"\xcf\x04\n" + + "\fVodJobStatus\x12\x15\n" + + "\x06job_id\x18\x01 \x01(\tR\x05jobId\x12\"\n" + + "\x05state\x18\x02 \x01(\x0e2\f.VodJobStateR\x05state\x12(\n" + + "\bprogress\x18\x03 \x01(\v2\f.VodProgressR\bprogress\x12)\n" + + "\x10checkpoint_token\x18\x04 \x01(\tR\x0fcheckpointToken\x122\n" + + "\fpause_reason\x18\x05 \x01(\x0e2\x0f.VodPauseReasonR\vpauseReason\x12\x1d\n" + + "\n" + + "error_code\x18\x06 \x01(\tR\terrorCode\x12#\n" + + "\rerror_message\x18\a \x01(\tR\ferrorMessage\x12\x1f\n" + + "\voutput_path\x18\b \x01(\tR\n" + + "outputPath\x12+\n" + + "\x12updated_at_unix_ms\x18\t \x01(\x03R\x0fupdatedAtUnixMs\x12\x14\n" + + "\x05width\x18\n" + + " \x01(\rR\x05width\x12\x16\n" + + "\x06height\x18\v \x01(\rR\x06height\x12$\n" + + "\x0eavg_frame_rate\x18\f \x01(\x01R\favgFrameRate\x12)\n" + + "\x10duration_seconds\x18\r \x01(\x01R\x0fdurationSeconds\x12\x1b\n" + + "\thas_audio\x18\x0e \x01(\bR\bhasAudio\x12(\n" + + "\x10input_size_bytes\x18\x10 \x01(\x04R\x0einputSizeBytes\x12#\n" + + "\rworker_target\x18\x0f \x01(\tR\fworkerTarget*\x8b\x01\n" + "\x15FaceAnonymizationMode\x12 \n" + "\x1cFACE_ANONYMIZATION_MODE_BLUR\x10\x00\x12%\n" + "!FACE_ANONYMIZATION_MODE_FACE_SWAP\x10\x01\x12)\n" + @@ -2047,7 +3061,23 @@ const file_api_proto_ai_processor_proto_rawDesc = "" + "\x0fVideoOutputMode\x12!\n" + "\x1dVIDEO_OUTPUT_MODE_UNSPECIFIED\x10\x00\x12#\n" + "\x1fVIDEO_OUTPUT_MODE_METADATA_ONLY\x10\x01\x12!\n" + - "\x1dVIDEO_OUTPUT_MODE_MOSAIC_JPEG\x10\x022\xbb\x04\n" + + "\x1dVIDEO_OUTPUT_MODE_MOSAIC_JPEG\x10\x02*\xb3\x01\n" + + "\vVodJobState\x12\x1d\n" + + "\x19VOD_JOB_STATE_UNSPECIFIED\x10\x00\x12\x1c\n" + + "\x18VOD_JOB_STATE_PROCESSING\x10\x01\x12\x18\n" + + "\x14VOD_JOB_STATE_PAUSED\x10\x02\x12\x16\n" + + "\x12VOD_JOB_STATE_DONE\x10\x03\x12\x18\n" + + "\x14VOD_JOB_STATE_FAILED\x10\x04\x12\x1b\n" + + "\x17VOD_JOB_STATE_CANCELLED\x10\x05*<\n" + + "\n" + + "VodQuality\x12\x18\n" + + "\x14VOD_QUALITY_STANDARD\x10\x00\x12\x14\n" + + "\x10VOD_QUALITY_HIGH\x10\x01*\x9f\x01\n" + + "\x0eVodPauseReason\x12 \n" + + "\x1cVOD_PAUSE_REASON_UNSPECIFIED\x10\x00\x12\x1e\n" + + "\x1aVOD_PAUSE_REASON_REQUESTED\x10\x01\x12$\n" + + " VOD_PAUSE_REASON_WORKER_DRAINING\x10\x02\x12%\n" + + "!VOD_PAUSE_REASON_RUNTIME_RECOVERY\x10\x032\xbb\x04\n" + "\vAiProcessor\x125\n" + "\fProcessVideo\x12\v.VideoChunk\x1a\x14.ProcessedVideoChunk(\x010\x01\x12-\n" + "\fAddWhitelist\x12\t.FaceData\x1a\x12.WhitelistResponse\x12>\n" + @@ -2057,7 +3087,16 @@ const file_api_proto_ai_processor_proto_rawDesc = "" + "\fListSessions\x12\x14.ListSessionsRequest\x1a\x15.ListSessionsResponse\x12>\n" + "\rDeleteSession\x12\x15.DeleteSessionRequest\x1a\x16.google.protobuf.Empty\x12D\n" + "\x0fExportWhitelist\x12\x17.ExportWhitelistRequest\x1a\x18.ExportWhitelistResponse\x12>\n" + - "\x0fImportWhitelist\x12\x17.ImportWhitelistRequest\x1a\x12.WhitelistResponseB$Z\"inno-live-server/api/gen/aiv1;aiv1b\x06proto3" + "\x0fImportWhitelist\x12\x17.ImportWhitelistRequest\x1a\x12.WhitelistResponse2\xb7\x02\n" + + "\fVodProcessor\x121\n" + + "\vStartVodJob\x12\x13.StartVodJobRequest\x1a\r.VodJobStatus\x12*\n" + + "\vWatchVodJob\x12\n" + + ".VodJobRef\x1a\r.VodJobStatus0\x01\x12&\n" + + "\tGetVodJob\x12\n" + + ".VodJobRef\x1a\r.VodJobStatus\x121\n" + + "\vPauseVodJob\x12\x13.PauseVodJobRequest\x1a\r.VodJobStatus\x123\n" + + "\fCancelVodJob\x12\x14.CancelVodJobRequest\x1a\r.VodJobStatus\x128\n" + + "\vListVodJobs\x12\x13.ListVodJobsRequest\x1a\x14.ListVodJobsResponseB$Z\"inno-live-server/api/gen/aiv1;aiv1b\x06proto3" var ( file_api_proto_ai_processor_proto_rawDescOnce sync.Once @@ -2071,76 +3110,111 @@ func file_api_proto_ai_processor_proto_rawDescGZIP() []byte { return file_api_proto_ai_processor_proto_rawDescData } -var file_api_proto_ai_processor_proto_enumTypes = make([]protoimpl.EnumInfo, 2) -var file_api_proto_ai_processor_proto_msgTypes = make([]protoimpl.MessageInfo, 25) +var file_api_proto_ai_processor_proto_enumTypes = make([]protoimpl.EnumInfo, 5) +var file_api_proto_ai_processor_proto_msgTypes = make([]protoimpl.MessageInfo, 35) var file_api_proto_ai_processor_proto_goTypes = []any{ (FaceAnonymizationMode)(0), // 0: FaceAnonymizationMode (VideoOutputMode)(0), // 1: VideoOutputMode - (*VideoChunk)(nil), // 2: VideoChunk - (*FaceAttributes)(nil), // 3: FaceAttributes - (*FaceAnonymization)(nil), // 4: FaceAnonymization - (*MosaicConfig)(nil), // 5: MosaicConfig - (*ProcessedVideoChunk)(nil), // 6: ProcessedVideoChunk - (*ProcessingTiming)(nil), // 7: ProcessingTiming - (*Point)(nil), // 8: Point - (*BoundingBox)(nil), // 9: BoundingBox - (*FaceMetadata)(nil), // 10: FaceMetadata - (*FrameStats)(nil), // 11: FrameStats - (*FaceData)(nil), // 12: FaceData - (*WhitelistResponse)(nil), // 13: WhitelistResponse - (*GetWhitelistStatusRequest)(nil), // 14: GetWhitelistStatusRequest - (*GetWhitelistStatusResponse)(nil), // 15: GetWhitelistStatusResponse - (*DeleteWhitelistRequest)(nil), // 16: DeleteWhitelistRequest - (*WhitelistEmbedding)(nil), // 17: WhitelistEmbedding - (*ExportWhitelistRequest)(nil), // 18: ExportWhitelistRequest - (*ExportWhitelistResponse)(nil), // 19: ExportWhitelistResponse - (*ImportWhitelistRequest)(nil), // 20: ImportWhitelistRequest - (*CreateSessionRequest)(nil), // 21: CreateSessionRequest - (*ListSessionsRequest)(nil), // 22: ListSessionsRequest - (*DeleteSessionRequest)(nil), // 23: DeleteSessionRequest - (*SessionInfo)(nil), // 24: SessionInfo - (*ListSessionsResponse)(nil), // 25: ListSessionsResponse - nil, // 26: FaceAttributes.ConfidenceEntry - (*emptypb.Empty)(nil), // 27: google.protobuf.Empty + (VodJobState)(0), // 2: VodJobState + (VodQuality)(0), // 3: VodQuality + (VodPauseReason)(0), // 4: VodPauseReason + (*VideoChunk)(nil), // 5: VideoChunk + (*FaceAttributes)(nil), // 6: FaceAttributes + (*FaceAnonymization)(nil), // 7: FaceAnonymization + (*MosaicConfig)(nil), // 8: MosaicConfig + (*ProcessedVideoChunk)(nil), // 9: ProcessedVideoChunk + (*ProcessingTiming)(nil), // 10: ProcessingTiming + (*Point)(nil), // 11: Point + (*BoundingBox)(nil), // 12: BoundingBox + (*FaceMetadata)(nil), // 13: FaceMetadata + (*FrameStats)(nil), // 14: FrameStats + (*FaceData)(nil), // 15: FaceData + (*WhitelistResponse)(nil), // 16: WhitelistResponse + (*GetWhitelistStatusRequest)(nil), // 17: GetWhitelistStatusRequest + (*GetWhitelistStatusResponse)(nil), // 18: GetWhitelistStatusResponse + (*DeleteWhitelistRequest)(nil), // 19: DeleteWhitelistRequest + (*WhitelistEmbedding)(nil), // 20: WhitelistEmbedding + (*ExportWhitelistRequest)(nil), // 21: ExportWhitelistRequest + (*ExportWhitelistResponse)(nil), // 22: ExportWhitelistResponse + (*ImportWhitelistRequest)(nil), // 23: ImportWhitelistRequest + (*CreateSessionRequest)(nil), // 24: CreateSessionRequest + (*ListSessionsRequest)(nil), // 25: ListSessionsRequest + (*DeleteSessionRequest)(nil), // 26: DeleteSessionRequest + (*SessionInfo)(nil), // 27: SessionInfo + (*ListSessionsResponse)(nil), // 28: ListSessionsResponse + (*VodWhitelist)(nil), // 29: VodWhitelist + (*VodOutputOptions)(nil), // 30: VodOutputOptions + (*StartVodJobRequest)(nil), // 31: StartVodJobRequest + (*VodJobRef)(nil), // 32: VodJobRef + (*PauseVodJobRequest)(nil), // 33: PauseVodJobRequest + (*CancelVodJobRequest)(nil), // 34: CancelVodJobRequest + (*ListVodJobsRequest)(nil), // 35: ListVodJobsRequest + (*ListVodJobsResponse)(nil), // 36: ListVodJobsResponse + (*VodProgress)(nil), // 37: VodProgress + (*VodJobStatus)(nil), // 38: VodJobStatus + nil, // 39: FaceAttributes.ConfidenceEntry + (*emptypb.Empty)(nil), // 40: google.protobuf.Empty } var file_api_proto_ai_processor_proto_depIdxs = []int32{ 1, // 0: VideoChunk.output_mode:type_name -> VideoOutputMode - 5, // 1: VideoChunk.mosaic_config:type_name -> MosaicConfig + 8, // 1: VideoChunk.mosaic_config:type_name -> MosaicConfig 0, // 2: VideoChunk.anonymization_mode:type_name -> FaceAnonymizationMode - 26, // 3: FaceAttributes.confidence:type_name -> FaceAttributes.ConfidenceEntry - 3, // 4: FaceAnonymization.attributes:type_name -> FaceAttributes - 10, // 5: ProcessedVideoChunk.faces:type_name -> FaceMetadata - 7, // 6: ProcessedVideoChunk.timing:type_name -> ProcessingTiming - 11, // 7: ProcessedVideoChunk.stats:type_name -> FrameStats - 9, // 8: FaceMetadata.bbox:type_name -> BoundingBox - 8, // 9: FaceMetadata.polygon:type_name -> Point - 4, // 10: FaceMetadata.anonymization:type_name -> FaceAnonymization - 17, // 11: ExportWhitelistResponse.entries:type_name -> WhitelistEmbedding - 17, // 12: ImportWhitelistRequest.entries:type_name -> WhitelistEmbedding - 24, // 13: ListSessionsResponse.sessions:type_name -> SessionInfo - 2, // 14: AiProcessor.ProcessVideo:input_type -> VideoChunk - 12, // 15: AiProcessor.AddWhitelist:input_type -> FaceData - 16, // 16: AiProcessor.DeleteWhitelist:input_type -> DeleteWhitelistRequest - 14, // 17: AiProcessor.GetWhitelistStatus:input_type -> GetWhitelistStatusRequest - 21, // 18: AiProcessor.CreateSession:input_type -> CreateSessionRequest - 22, // 19: AiProcessor.ListSessions:input_type -> ListSessionsRequest - 23, // 20: AiProcessor.DeleteSession:input_type -> DeleteSessionRequest - 18, // 21: AiProcessor.ExportWhitelist:input_type -> ExportWhitelistRequest - 20, // 22: AiProcessor.ImportWhitelist:input_type -> ImportWhitelistRequest - 6, // 23: AiProcessor.ProcessVideo:output_type -> ProcessedVideoChunk - 13, // 24: AiProcessor.AddWhitelist:output_type -> WhitelistResponse - 13, // 25: AiProcessor.DeleteWhitelist:output_type -> WhitelistResponse - 15, // 26: AiProcessor.GetWhitelistStatus:output_type -> GetWhitelistStatusResponse - 24, // 27: AiProcessor.CreateSession:output_type -> SessionInfo - 25, // 28: AiProcessor.ListSessions:output_type -> ListSessionsResponse - 27, // 29: AiProcessor.DeleteSession:output_type -> google.protobuf.Empty - 19, // 30: AiProcessor.ExportWhitelist:output_type -> ExportWhitelistResponse - 13, // 31: AiProcessor.ImportWhitelist:output_type -> WhitelistResponse - 23, // [23:32] is the sub-list for method output_type - 14, // [14:23] is the sub-list for method input_type - 14, // [14:14] is the sub-list for extension type_name - 14, // [14:14] is the sub-list for extension extendee - 0, // [0:14] is the sub-list for field type_name + 39, // 3: FaceAttributes.confidence:type_name -> FaceAttributes.ConfidenceEntry + 6, // 4: FaceAnonymization.attributes:type_name -> FaceAttributes + 13, // 5: ProcessedVideoChunk.faces:type_name -> FaceMetadata + 10, // 6: ProcessedVideoChunk.timing:type_name -> ProcessingTiming + 14, // 7: ProcessedVideoChunk.stats:type_name -> FrameStats + 12, // 8: FaceMetadata.bbox:type_name -> BoundingBox + 11, // 9: FaceMetadata.polygon:type_name -> Point + 7, // 10: FaceMetadata.anonymization:type_name -> FaceAnonymization + 20, // 11: ExportWhitelistResponse.entries:type_name -> WhitelistEmbedding + 20, // 12: ImportWhitelistRequest.entries:type_name -> WhitelistEmbedding + 27, // 13: ListSessionsResponse.sessions:type_name -> SessionInfo + 20, // 14: VodWhitelist.entries:type_name -> WhitelistEmbedding + 29, // 15: StartVodJobRequest.whitelist:type_name -> VodWhitelist + 3, // 16: StartVodJobRequest.quality:type_name -> VodQuality + 8, // 17: StartVodJobRequest.mosaic_config:type_name -> MosaicConfig + 0, // 18: StartVodJobRequest.anonymization_mode:type_name -> FaceAnonymizationMode + 30, // 19: StartVodJobRequest.output:type_name -> VodOutputOptions + 38, // 20: ListVodJobsResponse.jobs:type_name -> VodJobStatus + 2, // 21: VodJobStatus.state:type_name -> VodJobState + 37, // 22: VodJobStatus.progress:type_name -> VodProgress + 4, // 23: VodJobStatus.pause_reason:type_name -> VodPauseReason + 5, // 24: AiProcessor.ProcessVideo:input_type -> VideoChunk + 15, // 25: AiProcessor.AddWhitelist:input_type -> FaceData + 19, // 26: AiProcessor.DeleteWhitelist:input_type -> DeleteWhitelistRequest + 17, // 27: AiProcessor.GetWhitelistStatus:input_type -> GetWhitelistStatusRequest + 24, // 28: AiProcessor.CreateSession:input_type -> CreateSessionRequest + 25, // 29: AiProcessor.ListSessions:input_type -> ListSessionsRequest + 26, // 30: AiProcessor.DeleteSession:input_type -> DeleteSessionRequest + 21, // 31: AiProcessor.ExportWhitelist:input_type -> ExportWhitelistRequest + 23, // 32: AiProcessor.ImportWhitelist:input_type -> ImportWhitelistRequest + 31, // 33: VodProcessor.StartVodJob:input_type -> StartVodJobRequest + 32, // 34: VodProcessor.WatchVodJob:input_type -> VodJobRef + 32, // 35: VodProcessor.GetVodJob:input_type -> VodJobRef + 33, // 36: VodProcessor.PauseVodJob:input_type -> PauseVodJobRequest + 34, // 37: VodProcessor.CancelVodJob:input_type -> CancelVodJobRequest + 35, // 38: VodProcessor.ListVodJobs:input_type -> ListVodJobsRequest + 9, // 39: AiProcessor.ProcessVideo:output_type -> ProcessedVideoChunk + 16, // 40: AiProcessor.AddWhitelist:output_type -> WhitelistResponse + 16, // 41: AiProcessor.DeleteWhitelist:output_type -> WhitelistResponse + 18, // 42: AiProcessor.GetWhitelistStatus:output_type -> GetWhitelistStatusResponse + 27, // 43: AiProcessor.CreateSession:output_type -> SessionInfo + 28, // 44: AiProcessor.ListSessions:output_type -> ListSessionsResponse + 40, // 45: AiProcessor.DeleteSession:output_type -> google.protobuf.Empty + 22, // 46: AiProcessor.ExportWhitelist:output_type -> ExportWhitelistResponse + 16, // 47: AiProcessor.ImportWhitelist:output_type -> WhitelistResponse + 38, // 48: VodProcessor.StartVodJob:output_type -> VodJobStatus + 38, // 49: VodProcessor.WatchVodJob:output_type -> VodJobStatus + 38, // 50: VodProcessor.GetVodJob:output_type -> VodJobStatus + 38, // 51: VodProcessor.PauseVodJob:output_type -> VodJobStatus + 38, // 52: VodProcessor.CancelVodJob:output_type -> VodJobStatus + 36, // 53: VodProcessor.ListVodJobs:output_type -> ListVodJobsResponse + 39, // [39:54] is the sub-list for method output_type + 24, // [24:39] is the sub-list for method input_type + 24, // [24:24] is the sub-list for extension type_name + 24, // [24:24] is the sub-list for extension extendee + 0, // [0:24] is the sub-list for field type_name } func init() { file_api_proto_ai_processor_proto_init() } @@ -2150,15 +3224,19 @@ func file_api_proto_ai_processor_proto_init() { } file_api_proto_ai_processor_proto_msgTypes[3].OneofWrappers = []any{} file_api_proto_ai_processor_proto_msgTypes[8].OneofWrappers = []any{} + file_api_proto_ai_processor_proto_msgTypes[26].OneofWrappers = []any{ + (*StartVodJobRequest_Whitelist)(nil), + (*StartVodJobRequest_WhitelistSessionId)(nil), + } type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_api_proto_ai_processor_proto_rawDesc), len(file_api_proto_ai_processor_proto_rawDesc)), - NumEnums: 2, - NumMessages: 25, + NumEnums: 5, + NumMessages: 35, NumExtensions: 0, - NumServices: 1, + NumServices: 2, }, GoTypes: file_api_proto_ai_processor_proto_goTypes, DependencyIndexes: file_api_proto_ai_processor_proto_depIdxs, diff --git a/api/gen/aiv1/ai_processor_grpc.pb.go b/api/gen/aiv1/ai_processor_grpc.pb.go index f89b1d3..8bab015 100644 --- a/api/gen/aiv1/ai_processor_grpc.pb.go +++ b/api/gen/aiv1/ai_processor_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.6.2 -// - protoc v6.33.2 +// - protoc v6.33.0 // source: api/proto/ai_processor.proto package aiv1 @@ -441,3 +441,321 @@ var AiProcessor_ServiceDesc = grpc.ServiceDesc{ }, Metadata: "api/proto/ai_processor.proto", } + +const ( + VodProcessor_StartVodJob_FullMethodName = "/VodProcessor/StartVodJob" + VodProcessor_WatchVodJob_FullMethodName = "/VodProcessor/WatchVodJob" + VodProcessor_GetVodJob_FullMethodName = "/VodProcessor/GetVodJob" + VodProcessor_PauseVodJob_FullMethodName = "/VodProcessor/PauseVodJob" + VodProcessor_CancelVodJob_FullMethodName = "/VodProcessor/CancelVodJob" + VodProcessor_ListVodJobs_FullMethodName = "/VodProcessor/ListVodJobs" +) + +// VodProcessorClient is the client API for VodProcessor service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// File-in/file-out VOD anonymization on the worker's already-loaded runtime. +// Live ProcessVideo frames always take precedence on the shared inference lane. +type VodProcessorClient interface { + // Starts a new job or resumes one from its checkpoint. Returns once the job is + // admitted (inputs probed, outputs reserved); processing continues asynchronously. + StartVodJob(ctx context.Context, in *StartVodJobRequest, opts ...grpc.CallOption) (*VodJobStatus, error) + // Streams status: first message is the current snapshot, then progress updates. + // Ends after a PAUSED, DONE, FAILED, or CANCELLED message. Closing it never affects the job. + WatchVodJob(ctx context.Context, in *VodJobRef, opts ...grpc.CallOption) (grpc.ServerStreamingClient[VodJobStatus], error) + // One-shot snapshot. + GetVodJob(ctx context.Context, in *VodJobRef, opts ...grpc.CallOption) (*VodJobStatus, error) + // Stops GPU use within one frame, persists the last completed segment, returns PAUSED. + PauseVodJob(ctx context.Context, in *PauseVodJobRequest, opts ...grpc.CallOption) (*VodJobStatus, error) + // Stops the job, deletes partial output; with purge_work_dir also deletes checkpoint/segments. + CancelVodJob(ctx context.Context, in *CancelVodJobRequest, opts ...grpc.CallOption) (*VodJobStatus, error) + // Jobs known to this worker process (for server reconciliation after restarts/slot switches). + ListVodJobs(ctx context.Context, in *ListVodJobsRequest, opts ...grpc.CallOption) (*ListVodJobsResponse, error) +} + +type vodProcessorClient struct { + cc grpc.ClientConnInterface +} + +func NewVodProcessorClient(cc grpc.ClientConnInterface) VodProcessorClient { + return &vodProcessorClient{cc} +} + +func (c *vodProcessorClient) StartVodJob(ctx context.Context, in *StartVodJobRequest, opts ...grpc.CallOption) (*VodJobStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VodJobStatus) + err := c.cc.Invoke(ctx, VodProcessor_StartVodJob_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vodProcessorClient) WatchVodJob(ctx context.Context, in *VodJobRef, opts ...grpc.CallOption) (grpc.ServerStreamingClient[VodJobStatus], error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + stream, err := c.cc.NewStream(ctx, &VodProcessor_ServiceDesc.Streams[0], VodProcessor_WatchVodJob_FullMethodName, cOpts...) + if err != nil { + return nil, err + } + x := &grpc.GenericClientStream[VodJobRef, VodJobStatus]{ClientStream: stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type VodProcessor_WatchVodJobClient = grpc.ServerStreamingClient[VodJobStatus] + +func (c *vodProcessorClient) GetVodJob(ctx context.Context, in *VodJobRef, opts ...grpc.CallOption) (*VodJobStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VodJobStatus) + err := c.cc.Invoke(ctx, VodProcessor_GetVodJob_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vodProcessorClient) PauseVodJob(ctx context.Context, in *PauseVodJobRequest, opts ...grpc.CallOption) (*VodJobStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VodJobStatus) + err := c.cc.Invoke(ctx, VodProcessor_PauseVodJob_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vodProcessorClient) CancelVodJob(ctx context.Context, in *CancelVodJobRequest, opts ...grpc.CallOption) (*VodJobStatus, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(VodJobStatus) + err := c.cc.Invoke(ctx, VodProcessor_CancelVodJob_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *vodProcessorClient) ListVodJobs(ctx context.Context, in *ListVodJobsRequest, opts ...grpc.CallOption) (*ListVodJobsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListVodJobsResponse) + err := c.cc.Invoke(ctx, VodProcessor_ListVodJobs_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// VodProcessorServer is the server API for VodProcessor service. +// All implementations must embed UnimplementedVodProcessorServer +// for forward compatibility. +// +// File-in/file-out VOD anonymization on the worker's already-loaded runtime. +// Live ProcessVideo frames always take precedence on the shared inference lane. +type VodProcessorServer interface { + // Starts a new job or resumes one from its checkpoint. Returns once the job is + // admitted (inputs probed, outputs reserved); processing continues asynchronously. + StartVodJob(context.Context, *StartVodJobRequest) (*VodJobStatus, error) + // Streams status: first message is the current snapshot, then progress updates. + // Ends after a PAUSED, DONE, FAILED, or CANCELLED message. Closing it never affects the job. + WatchVodJob(*VodJobRef, grpc.ServerStreamingServer[VodJobStatus]) error + // One-shot snapshot. + GetVodJob(context.Context, *VodJobRef) (*VodJobStatus, error) + // Stops GPU use within one frame, persists the last completed segment, returns PAUSED. + PauseVodJob(context.Context, *PauseVodJobRequest) (*VodJobStatus, error) + // Stops the job, deletes partial output; with purge_work_dir also deletes checkpoint/segments. + CancelVodJob(context.Context, *CancelVodJobRequest) (*VodJobStatus, error) + // Jobs known to this worker process (for server reconciliation after restarts/slot switches). + ListVodJobs(context.Context, *ListVodJobsRequest) (*ListVodJobsResponse, error) + mustEmbedUnimplementedVodProcessorServer() +} + +// UnimplementedVodProcessorServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedVodProcessorServer struct{} + +func (UnimplementedVodProcessorServer) StartVodJob(context.Context, *StartVodJobRequest) (*VodJobStatus, error) { + return nil, status.Error(codes.Unimplemented, "method StartVodJob not implemented") +} +func (UnimplementedVodProcessorServer) WatchVodJob(*VodJobRef, grpc.ServerStreamingServer[VodJobStatus]) error { + return status.Error(codes.Unimplemented, "method WatchVodJob not implemented") +} +func (UnimplementedVodProcessorServer) GetVodJob(context.Context, *VodJobRef) (*VodJobStatus, error) { + return nil, status.Error(codes.Unimplemented, "method GetVodJob not implemented") +} +func (UnimplementedVodProcessorServer) PauseVodJob(context.Context, *PauseVodJobRequest) (*VodJobStatus, error) { + return nil, status.Error(codes.Unimplemented, "method PauseVodJob not implemented") +} +func (UnimplementedVodProcessorServer) CancelVodJob(context.Context, *CancelVodJobRequest) (*VodJobStatus, error) { + return nil, status.Error(codes.Unimplemented, "method CancelVodJob not implemented") +} +func (UnimplementedVodProcessorServer) ListVodJobs(context.Context, *ListVodJobsRequest) (*ListVodJobsResponse, error) { + return nil, status.Error(codes.Unimplemented, "method ListVodJobs not implemented") +} +func (UnimplementedVodProcessorServer) mustEmbedUnimplementedVodProcessorServer() {} +func (UnimplementedVodProcessorServer) testEmbeddedByValue() {} + +// UnsafeVodProcessorServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to VodProcessorServer will +// result in compilation errors. +type UnsafeVodProcessorServer interface { + mustEmbedUnimplementedVodProcessorServer() +} + +func RegisterVodProcessorServer(s grpc.ServiceRegistrar, srv VodProcessorServer) { + // If the following call panics, it indicates UnimplementedVodProcessorServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&VodProcessor_ServiceDesc, srv) +} + +func _VodProcessor_StartVodJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(StartVodJobRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VodProcessorServer).StartVodJob(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VodProcessor_StartVodJob_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VodProcessorServer).StartVodJob(ctx, req.(*StartVodJobRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VodProcessor_WatchVodJob_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(VodJobRef) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(VodProcessorServer).WatchVodJob(m, &grpc.GenericServerStream[VodJobRef, VodJobStatus]{ServerStream: stream}) +} + +// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. +type VodProcessor_WatchVodJobServer = grpc.ServerStreamingServer[VodJobStatus] + +func _VodProcessor_GetVodJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(VodJobRef) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VodProcessorServer).GetVodJob(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VodProcessor_GetVodJob_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VodProcessorServer).GetVodJob(ctx, req.(*VodJobRef)) + } + return interceptor(ctx, in, info, handler) +} + +func _VodProcessor_PauseVodJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(PauseVodJobRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VodProcessorServer).PauseVodJob(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VodProcessor_PauseVodJob_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VodProcessorServer).PauseVodJob(ctx, req.(*PauseVodJobRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VodProcessor_CancelVodJob_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelVodJobRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VodProcessorServer).CancelVodJob(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VodProcessor_CancelVodJob_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VodProcessorServer).CancelVodJob(ctx, req.(*CancelVodJobRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _VodProcessor_ListVodJobs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListVodJobsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(VodProcessorServer).ListVodJobs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: VodProcessor_ListVodJobs_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(VodProcessorServer).ListVodJobs(ctx, req.(*ListVodJobsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// VodProcessor_ServiceDesc is the grpc.ServiceDesc for VodProcessor service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var VodProcessor_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "VodProcessor", + HandlerType: (*VodProcessorServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "StartVodJob", + Handler: _VodProcessor_StartVodJob_Handler, + }, + { + MethodName: "GetVodJob", + Handler: _VodProcessor_GetVodJob_Handler, + }, + { + MethodName: "PauseVodJob", + Handler: _VodProcessor_PauseVodJob_Handler, + }, + { + MethodName: "CancelVodJob", + Handler: _VodProcessor_CancelVodJob_Handler, + }, + { + MethodName: "ListVodJobs", + Handler: _VodProcessor_ListVodJobs_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "WatchVodJob", + Handler: _VodProcessor_WatchVodJob_Handler, + ServerStreams: true, + }, + }, + Metadata: "api/proto/ai_processor.proto", +} diff --git a/api/proto/ai_processor.proto b/api/proto/ai_processor.proto index caf5412..8e4c224 100644 --- a/api/proto/ai_processor.proto +++ b/api/proto/ai_processor.proto @@ -241,3 +241,138 @@ message SessionInfo { message ListSessionsResponse { repeated SessionInfo sessions = 1; } + +// File-in/file-out VOD anonymization on the worker's already-loaded runtime. +// Live ProcessVideo frames always take precedence on the shared inference lane. +service VodProcessor { + // Starts a new job or resumes one from its checkpoint. Returns once the job is + // admitted (inputs probed, outputs reserved); processing continues asynchronously. + rpc StartVodJob(StartVodJobRequest) returns (VodJobStatus); + // Streams status: first message is the current snapshot, then progress updates. + // Ends after a PAUSED, DONE, FAILED, or CANCELLED message. Closing it never affects the job. + rpc WatchVodJob(VodJobRef) returns (stream VodJobStatus); + // One-shot snapshot. + rpc GetVodJob(VodJobRef) returns (VodJobStatus); + // Stops GPU use within one frame, persists the last completed segment, returns PAUSED. + rpc PauseVodJob(PauseVodJobRequest) returns (VodJobStatus); + // Stops the job, deletes partial output; with purge_work_dir also deletes checkpoint/segments. + rpc CancelVodJob(CancelVodJobRequest) returns (VodJobStatus); + // Jobs known to this worker process (for server reconciliation after restarts/slot switches). + rpc ListVodJobs(ListVodJobsRequest) returns (ListVodJobsResponse); +} + +enum VodJobState { + VOD_JOB_STATE_UNSPECIFIED = 0; + VOD_JOB_STATE_PROCESSING = 1; + VOD_JOB_STATE_PAUSED = 2; + VOD_JOB_STATE_DONE = 3; + VOD_JOB_STATE_FAILED = 4; + VOD_JOB_STATE_CANCELLED = 5; +} + +enum VodQuality { + // Mode A: one pass, live-parity pipeline (detection→tracking→recognition→mosaic). + VOD_QUALITY_STANDARD = 0; + // Mode B (high precision: lookahead/backfill, multi-pass). Rejected with + // FAILED_PRECONDITION until implemented; field reserved now so the server need not change. + VOD_QUALITY_HIGH = 1; +} + +enum VodPauseReason { + VOD_PAUSE_REASON_UNSPECIFIED = 0; + VOD_PAUSE_REASON_REQUESTED = 1; // server PauseVodJob (e.g. live demand) + VOD_PAUSE_REASON_WORKER_DRAINING = 2; // set_accepting(False) / rolling replace / SIGTERM + VOD_PAUSE_REASON_RUNTIME_RECOVERY = 3; // worker entered runtime recovery +} + +message VodWhitelist { + // Must equal ExportWhitelistResponse.embedding_model of this worker, else EMBEDDING_MODEL_MISMATCH. + string embedding_model = 1; + repeated WhitelistEmbedding entries = 2; // existing message; unit-norm AdaFace vectors +} + +message VodOutputOptions { + // "auto" (h264_nvenc when available, else libx264), "h264_nvenc", "libx264". + string video_encoder = 1; + // Constant-quality target: NVENC -cq / x264 -crf. 0 = default (20). + uint32 quality = 2; + // Re-encode audio to AAC even when stream copy is possible. + bool force_audio_aac = 3; +} + +message StartVodJobRequest { + // Server-generated stable ID (UUID). Same ID across pause/resume and across workers. + string job_id = 1; + // Absolute paths under the worker's --vod-root (shared local disk). Symlinks resolved; outside root → rejected. + string input_path = 2; + string output_path = 3; // final file, written as .part then renamed atomically on DONE + string work_dir = 4; // checkpoint.json + segments/; created if absent + // Registered-person exclusion. Empty entries = protect everyone. + oneof whitelist_source { + VodWhitelist whitelist = 5; // preferred: server ExportWhitelist()s the user's session from any worker + string whitelist_session_id = 6; // convenience: a session already on THIS worker; snapshot taken at start + } + VodQuality quality = 7; + MosaicConfig mosaic_config = 8; // existing message; fixed for the job + FaceAnonymizationMode anonymization_mode = 9; // v1 accepts BLUR only; others → FAILED_PRECONDITION + VodOutputOptions output = 10; + // false = new job (work_dir must have no checkpoint, output must not exist unless overwrite_output). + // true = resume from work_dir/checkpoint.json; checkpoint_token must match. + bool resume = 11; + string checkpoint_token = 12; + bool overwrite_output = 13; + // Segment length in source frames at which checkpoints are cut. 0 = default 300. + uint32 segment_frames = 14; +} + +message VodJobRef { + string job_id = 1; +} + +message PauseVodJobRequest { + string job_id = 1; +} + +message CancelVodJobRequest { + string job_id = 1; + bool purge_work_dir = 2; +} + +message ListVodJobsRequest {} + +message ListVodJobsResponse { + repeated VodJobStatus jobs = 1; +} + +message VodProgress { + uint64 frames_done = 1; // source frames fully processed and committed or in the current segment + uint64 frames_total = 2; // from ffprobe nb_frames, else round(duration × avg_frame_rate) + bool frames_total_estimated = 3; // true when nb_frames was absent + double fraction = 4; // frames_done / frames_total, clamped to [0,1]; 1.0 only at DONE + uint32 segments_done = 5; + uint32 segments_total = 6; + double processing_fps = 7; // EWMA over the last ~10 s of active processing + double eta_seconds = 8; // (frames_total-frames_done)/processing_fps; -1 if unknown +} + +message VodJobStatus { + string job_id = 1; + VodJobState state = 2; + VodProgress progress = 3; + // Opaque token naming the last durable checkpoint (empty before the first segment commits). + // Pass back in StartVodJobRequest.checkpoint_token to resume. + string checkpoint_token = 4; + VodPauseReason pause_reason = 5; + string error_code = 6; // §6, set for FAILED (and CANCELLED = "CANCELLED") + string error_message = 7; + string output_path = 8; // set at DONE + int64 updated_at_unix_ms = 9; + // Source facts from probe (set from admission onward). + uint32 width = 10; + uint32 height = 11; + double avg_frame_rate = 12; + double duration_seconds = 13; + bool has_audio = 14; + uint64 input_size_bytes = 16; // stat size at admission, checked against --vod-max-input-bytes + string worker_target = 15; // host:port this worker listens on, for server bookkeeping +} diff --git a/internal/ai/vod_client.go b/internal/ai/vod_client.go new file mode 100644 index 0000000..891f12e --- /dev/null +++ b/internal/ai/vod_client.go @@ -0,0 +1,6 @@ +package ai + +import aiv1 "inno-live-server/api/gen/aiv1" + +// VOD reuses the existing connection, message limits and worker lifecycle. +func (c *Client) VOD() aiv1.VodProcessorClient { return aiv1.NewVodProcessorClient(c.conn) } diff --git a/internal/vod/worker.go b/internal/vod/worker.go new file mode 100644 index 0000000..9c40903 --- /dev/null +++ b/internal/vod/worker.go @@ -0,0 +1,137 @@ +package vod + +import ( + "context" + "errors" + "io" + "os/exec" + "strconv" + "strings" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + pb "inno-live-server/api/gen/aiv1" + "inno-live-server/internal/ai" +) + +// Worker is the interface-A contract. Callers own deadlines, cancellation and +// checkpoint persistence. Whitelist embeddings are never written to disk. +type Worker interface { + Start(context.Context, string, *pb.StartVodJobRequest) (*pb.VodJobStatus, error) + Watch(context.Context, string, string, func(*pb.VodJobStatus) error) error + Get(context.Context, string, string) (*pb.VodJobStatus, error) + Pause(context.Context, string, string) (*pb.VodJobStatus, error) + Cancel(context.Context, string, string) (*pb.VodJobStatus, error) + List(context.Context, string) ([]*pb.VodJobStatus, error) + Whitelist(context.Context, string) (*pb.VodWhitelist, error) +} +type GRPCWorker struct{ Pool *ai.Pool } + +func (w GRPCWorker) client(target string) (pb.VodProcessorClient, error) { + c := w.Pool.Target(target) + if c == nil { + return nil, status.Error(codes.Unavailable, "worker removed") + } + return c.VOD(), nil +} +func (w GRPCWorker) Start(ctx context.Context, t string, r *pb.StartVodJobRequest) (*pb.VodJobStatus, error) { + c, e := w.client(t) + if e != nil { + return nil, e + } + return c.StartVodJob(ctx, r) +} +func (w GRPCWorker) Get(ctx context.Context, t, id string) (*pb.VodJobStatus, error) { + c, e := w.client(t) + if e != nil { + return nil, e + } + return c.GetVodJob(ctx, &pb.VodJobRef{JobId: id}) +} +func (w GRPCWorker) Pause(ctx context.Context, t, id string) (*pb.VodJobStatus, error) { + c, e := w.client(t) + if e != nil { + return nil, e + } + return c.PauseVodJob(ctx, &pb.PauseVodJobRequest{JobId: id}) +} +func (w GRPCWorker) Cancel(ctx context.Context, t, id string) (*pb.VodJobStatus, error) { + c, e := w.client(t) + if e != nil { + return nil, e + } + return c.CancelVodJob(ctx, &pb.CancelVodJobRequest{JobId: id, PurgeWorkDir: true}) +} +func (w GRPCWorker) List(ctx context.Context, t string) ([]*pb.VodJobStatus, error) { + c, e := w.client(t) + if e != nil { + return nil, e + } + r, e := c.ListVodJobs(ctx, &pb.ListVodJobsRequest{}) + if e != nil { + return nil, e + } + return r.Jobs, nil +} +func (w GRPCWorker) Watch(ctx context.Context, t, id string, callback func(*pb.VodJobStatus) error) error { + c, e := w.client(t) + if e != nil { + return e + } + stream, e := c.WatchVodJob(ctx, &pb.VodJobRef{JobId: id}) + if e != nil { + return e + } + for { + r, e := stream.Recv() + if e == io.EOF { + return nil + } + if e != nil { + return e + } + if e = callback(r); e != nil { + return e + } + } +} +func (w GRPCWorker) Whitelist(ctx context.Context, session string) (*pb.VodWhitelist, error) { + var last error + for _, c := range w.Pool.Clients() { + r, e := c.ExportWhitelist(ctx, session) + if e == nil { + return &pb.VodWhitelist{EmbeddingModel: r.EmbeddingModel, Entries: r.Entries}, nil + } + last = e + } + // Never silently fall back after an export error: it could change resume identity. + if last == nil { + last = errors.New("no whitelist source") + } + return nil, last +} + +type MemoryProbe interface { + FreeMiB(context.Context) (map[string]int64, error) +} +type NvidiaMemory struct{} + +func (NvidiaMemory) FreeMiB(ctx context.Context) (map[string]int64, error) { + b, e := exec.CommandContext(ctx, "nvidia-smi", "--query-gpu=uuid,memory.free", "--format=csv,noheader,nounits").Output() + if e != nil { + return nil, e + } + result := map[string]int64{} + for _, line := range strings.Split(strings.TrimSpace(string(b)), "\n") { + fields := strings.Split(line, ",") + if len(fields) != 2 { + return nil, errors.New("invalid GPU telemetry") + } + n, e := strconv.ParseInt(strings.TrimSpace(fields[1]), 10, 64) + if e != nil || n < 0 { + return nil, errors.New("invalid GPU memory") + } + result[strings.TrimSpace(fields[0])] = n + } + return result, nil +} From e5a5a71297b9e267f2bc29f55aa4b3fc970656a3 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:57:42 +0900 Subject: [PATCH 06/23] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90?= =?UTF-8?q?=EB=B3=84=20VOD=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/vod/http.go | 414 ++++++++++++++++++++++++++++++++++++++++ internal/vod/service.go | 232 ++++++++++++++++++++++ 2 files changed, 646 insertions(+) create mode 100644 internal/vod/http.go create mode 100644 internal/vod/service.go diff --git a/internal/vod/http.go b/internal/vod/http.go new file mode 100644 index 0000000..98aa14a --- /dev/null +++ b/internal/vod/http.go @@ -0,0 +1,414 @@ +package vod + +import ( + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + "inno-live-server/internal/auth" +) + +// Register creates no routes when disabled. Authentication wraps every method. +func (s *Service) Register(mux *http.ServeMux, requireUser func(http.Handler) http.Handler) { + if !s.Config.Enabled { + return + } + for pattern, handler := range map[string]http.HandlerFunc{"POST /vod/jobs": s.upload, "GET /vod/jobs": s.list, "GET /vod/jobs/{job_id}": s.get, "DELETE /vod/jobs/{job_id}": s.delete, "GET /vod/jobs/{job_id}/result": s.result} { + authenticated := requireUser(handler) + mux.Handle(pattern, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + authenticated.ServeHTTP(w, r) + })) + } +} +func fail(w http.ResponseWriter, code int, key string, details ...map[string]any) { + body := map[string]any{"code": key, "message": strings.ReplaceAll(key, "_", " ")} + if len(details) > 0 { + body["details"] = details[0] + } + reply(w, code, map[string]any{"error": body}) +} +func reply(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} +func (s *Service) access(w http.ResponseWriter, r *http.Request) (*Store, uuid.UUID, bool) { + user, ok := auth.UserIDFromContext(r.Context()) + if !ok || user == uuid.Nil { + fail(w, 401, "unauthorized") + return nil, user, false + } + db := s.store.Load() + if db == nil { + fail(w, 503, "service_unavailable") + return nil, user, false + } + return db, user, true +} +func (s *Service) owned(w http.ResponseWriter, r *http.Request) (*Store, Job, bool) { + db, user, ok := s.access(w, r) + if !ok { + return nil, Job{}, false + } + id, e := uuid.Parse(r.PathValue("job_id")) + if e != nil { + fail(w, 400, "bad_request") + return nil, Job{}, false + } + j, e := db.Get(r.Context(), id, user) + if errors.Is(e, gorm.ErrRecordNotFound) { + fail(w, 404, "not_found") + return nil, j, false + } + if e != nil { + fail(w, 503, "service_unavailable") + return nil, j, false + } + return db, j, true +} +func (s *Service) upload(w http.ResponseWriter, r *http.Request) { + db, user, ok := s.access(w, r) + if !ok { + return + } + ctx, cancel := context.WithTimeout(r.Context(), s.Config.UploadTimeout) + defer cancel() + rc := http.NewResponseController(w) + _ = rc.SetReadDeadline(time.Now().Add(s.Config.UploadTimeout)) + defer func() { _ = rc.SetReadDeadline(time.Time{}) }() + maxBytes := s.Config.MaxUploadBytes + tooLarge := func() { + fail(w, 413, "upload_too_large", map[string]any{"max_upload_bytes": maxBytes, "max_request_bytes": maxBytes + 1048576}) + } + if r.ContentLength > maxBytes+1048576 { + tooLarge() + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxBytes+1048576) + reader, e := r.MultipartReader() + if e != nil { + fail(w, 400, "bad_request") + return + } + part, e := reader.NextPart() + if e != nil { + var capErr *http.MaxBytesError + if errors.As(e, &capErr) { + tooLarge() + } else { + fail(w, 400, "bad_request") + } + return + } + if part.FormName() != "file" || part.FileName() == "" { + fail(w, 400, "bad_request") + return + } + mime := part.Header.Get("Content-Type") + if mime != "video/mp4" && mime != "application/octet-stream" { + fail(w, 415, "unsupported_media_type") + return + } + now := s.Now() + j := Job{ID: uuid.New(), UserID: user, Status: "uploaded", CreatedAt: now, UpdatedAt: now, QueuedAt: now, Version: 1} + path := s.Path(j, "original") + dir := filepath.Dir(path) + if e = os.MkdirAll(dir, 0750); e != nil { + fail(w, 503, "service_unavailable") + return + } + accepted := false + defer func() { + if !accepted { + if e := os.RemoveAll(dir); e != nil { + s.Logger.Error("vod upload cleanup failed", "job_id", j.ID, "error", e) + } + } + }() + f, e := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640) + if e != nil { + fail(w, 503, "service_unavailable") + return + } + n, e := io.Copy(f, io.LimitReader(part, maxBytes+1)) + syncErr := f.Sync() + closeErr := f.Close() + var capErr *http.MaxBytesError + if n > maxBytes || errors.As(e, &capErr) { + tooLarge() + return + } + if e != nil { + fail(w, 400, "bad_request") + return + } + if syncErr != nil || closeErr != nil { + fail(w, 503, "service_unavailable") + return + } + if n == 0 { + fail(w, 400, "bad_request") + return + } + if _, e = reader.NextPart(); e != io.EOF { + if errors.As(e, &capErr) { + tooLarge() + } else { + fail(w, 400, "bad_request") + } + return + } + // Multipart epilogues count toward the request bound as well. + if _, e = io.Copy(io.Discard, r.Body); e != nil { + if errors.As(e, &capErr) { + tooLarge() + } else { + fail(w, 400, "bad_request") + } + return + } + media, e := s.Probe.Probe(ctx, path) + if errors.Is(e, ErrUnsupported) { + fail(w, 415, "unsupported_media_type") + return + } + if e != nil || media.Duration <= 0 { + fail(w, 422, "invalid_media") + return + } + if media.Duration > s.Config.MaxDuration.Seconds() { + fail(w, 422, "duration_exceeded", map[string]any{"max_duration_sec": s.Config.MaxDuration.Seconds()}) + return + } + j.DurationSec = media.Duration + j.FramesTotal = media.Frames + d, e := os.Open(dir) + if e == nil { + e = d.Sync() + _ = d.Close() + } + if e != nil { + fail(w, 503, "service_unavailable") + return + } + if e = db.Create(ctx, &j); e != nil { + fail(w, 503, "service_unavailable") + return + } + accepted = true + w.Header().Set("Location", "/vod/jobs/"+j.ID.String()) + reply(w, 201, s.View(j, nil)) +} +func (s *Service) get(w http.ResponseWriter, r *http.Request) { + db, j, ok := s.owned(w, r) + if !ok { + return + } + queue, e := db.All(r.Context()) + if e != nil { + fail(w, 503, "service_unavailable") + return + } + reply(w, 200, s.View(j, queue)) +} + +// Cursors use a process-local random key; restart invalidates pagination tokens. +// Cursor MAC is scoped to a user and includes the initial upper and last keys. +type cursor struct { + User string + Upper time.Time + At time.Time + ID string +} + +func (s *Service) list(w http.ResponseWriter, r *http.Request) { + db, user, ok := s.access(w, r) + if !ok { + return + } + limit := 20 + if raw, exists := r.URL.Query()["limit"]; exists { + var e error + limit, e = strconv.Atoi(raw[0]) + if e != nil || limit < 1 || limit > 100 { + fail(w, 400, "bad_request") + return + } + } + c := cursor{User: user.String(), Upper: s.Now()} + if raw, exists := r.URL.Query()["cursor"]; exists { + parts := strings.Split(raw[0], ".") + if len(parts) != 2 { + fail(w, 400, "bad_request") + return + } + b, e := base64.RawURLEncoding.DecodeString(parts[0]) + mac, e2 := base64.RawURLEncoding.DecodeString(parts[1]) + if e != nil || e2 != nil || !hmac.Equal(mac, s.cursorMAC(b)) || json.Unmarshal(b, &c) != nil || c.User != user.String() { + fail(w, 400, "bad_request") + return + } + } + queue, e := db.All(r.Context()) + if e != nil { + fail(w, 503, "service_unavailable") + return + } + jobs := make([]Job, 0) + for _, j := range queue { + if j.UserID != user || j.DeletedAt != nil || j.CreatedAt.After(c.Upper) { + continue + } + if !c.At.IsZero() && (j.CreatedAt.After(c.At) || (j.CreatedAt.Equal(c.At) && j.ID.String() >= c.ID)) { + continue + } + jobs = append(jobs, j) + } + sort.Slice(jobs, func(i, k int) bool { + if jobs[i].CreatedAt.Equal(jobs[k].CreatedAt) { + return jobs[i].ID.String() > jobs[k].ID.String() + } + return jobs[i].CreatedAt.After(jobs[k].CreatedAt) + }) + var next *string + if len(jobs) > limit { + jobs = jobs[:limit] + last := jobs[len(jobs)-1] + c.At = last.CreatedAt + c.ID = last.ID.String() + b, _ := json.Marshal(c) + v := base64.RawURLEncoding.EncodeToString(b) + "." + base64.RawURLEncoding.EncodeToString(s.cursorMAC(b)) + next = &v + } + views := make([]View, 0, len(jobs)) + for _, j := range jobs { + views = append(views, s.View(j, queue)) + } + reply(w, 200, map[string]any{"jobs": views, "next_cursor": next}) +} +func (s *Service) cursorMAC(b []byte) []byte { + h := hmac.New(sha256.New, s.cursorKey) + h.Write(b) + return h.Sum(nil) +} +func (s *Service) SetCursorKey(key []byte) { s.cursorKey = append([]byte{}, key...) } +func (s *Service) delete(w http.ResponseWriter, r *http.Request) { + db, j, ok := s.owned(w, r) + if !ok { + return + } + for attempt := 0; attempt < 5; attempt++ { + now := s.Now() + j.DeletedAt = &now + s.Finish(&j, "cancelled") + deadline := now.Add(s.Config.OriginalRetention) + if j.OriginalDeleteAt == nil || deadline.Before(*j.OriginalDeleteAt) { + j.OriginalDeleteAt = &deadline + } + e := db.Save(r.Context(), &j) + if e == nil { + s.WakeSweep() + w.WriteHeader(204) + return + } + if !errors.Is(e, ErrConflict) { + fail(w, 503, "service_unavailable") + return + } + j, e = db.Get(r.Context(), j.ID, j.UserID) + if e != nil { + fail(w, 404, "not_found") + return + } + } + fail(w, 503, "service_unavailable") +} +func (s *Service) result(w http.ResponseWriter, r *http.Request) { + db, j, ok := s.owned(w, r) + if !ok { + return + } + if j.Status == "expired" || (j.ResultExpiresAt != nil && !s.Now().Before(*j.ResultExpiresAt)) { + fail(w, 410, "result_expired") + return + } + if j.Status != "done" || j.ResultExpiresAt == nil { + fail(w, 409, "result_not_ready") + return + } + path := s.Path(j, "result") + info, e := os.Lstat(path) + if e != nil || !info.Mode().IsRegular() { + fail(w, 503, "service_unavailable") + return + } + f, e := os.Open(path) + if e != nil { + fail(w, 503, "service_unavailable") + return + } + defer f.Close() + rc := http.NewResponseController(w) + _ = rc.SetWriteDeadline(time.Now().Add(j.ResultExpiresAt.Sub(s.Now()))) + defer func() { _ = rc.SetWriteDeadline(time.Time{}) }() + ctx, cancel := context.WithCancel(r.Context()) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(200 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + current, e := db.Get(ctx, j.ID, j.UserID) + if e != nil || current.Status != "done" || !s.Now().Before(*j.ResultExpiresAt) { + _ = rc.SetWriteDeadline(time.Now()) + cancel() + return + } + } + } + }() + defer func() { cancel(); <-done }() + w.Header().Set("Content-Type", "video/mp4") + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"vod-%s.mp4\"", j.ID)) + w.Header().Set("Content-Length", strconv.FormatInt(info.Size(), 10)) + w.Header().Set("X-Content-Type-Options", "nosniff") + w.Header().Set("Accept-Ranges", "none") + w.WriteHeader(200) + buf := make([]byte, 64*1024) + for { + if ctx.Err() != nil || !s.Now().Before(*j.ResultExpiresAt) { + panic(http.ErrAbortHandler) + } + n, e := f.Read(buf) + if n > 0 { + if _, we := w.Write(buf[:n]); we != nil { + panic(http.ErrAbortHandler) + } + } + if e == io.EOF { + return + } + if e != nil { + panic(http.ErrAbortHandler) + } + } +} diff --git a/internal/vod/service.go b/internal/vod/service.go new file mode 100644 index 0000000..e11ebee --- /dev/null +++ b/internal/vod/service.go @@ -0,0 +1,232 @@ +package vod + +import ( + "context" + "crypto/rand" + "encoding/json" + "errors" + "log/slog" + "math" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync/atomic" + "time" + + "inno-live-server/internal/config" +) + +var ErrUnsupported = errors.New("unsupported media") +var ErrInvalidMedia = errors.New("invalid media") + +type Media struct { + Duration float64 + Frames uint64 +} +type Prober interface { + Probe(context.Context, string) (Media, error) +} +type FFProbe struct{} + +func (FFProbe) Probe(ctx context.Context, path string) (Media, error) { + b, e := exec.CommandContext(ctx, "ffprobe", "-v", "error", "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,nb_frames,width,height", "-of", "json", path).Output() + if e != nil { + return Media{}, ErrInvalidMedia + } + var data struct { + Format struct { + FormatName string `json:"format_name"` + Duration string + } + Streams []struct { + CodecType string `json:"codec_type"` + CodecName string `json:"codec_name"` + NBFrames string `json:"nb_frames"` + Width, Height int + } + } + if json.Unmarshal(b, &data) != nil { + return Media{}, ErrInvalidMedia + } + duration, e := strconv.ParseFloat(data.Format.Duration, 64) + if e != nil || duration <= 0 || math.IsNaN(duration) || math.IsInf(duration, 0) { + return Media{}, ErrInvalidMedia + } + if !strings.Contains(data.Format.FormatName, "mp4") { + return Media{}, ErrUnsupported + } + videos := 0 + var frames uint64 + for _, s := range data.Streams { + switch s.CodecType { + case "video": + videos++ + if s.CodecName != "h264" || s.Width > 1920 || s.Height > 1920 { + return Media{}, ErrUnsupported + } + frames, _ = strconv.ParseUint(s.NBFrames, 10, 64) + case "audio": + if s.CodecName != "aac" { + return Media{}, ErrUnsupported + } + } + } + if videos == 0 { + return Media{}, ErrInvalidMedia + } + if videos != 1 { + return Media{}, ErrUnsupported + } + return Media{duration, frames}, nil +} + +type Service struct { + Config config.VODConfig + Logger *slog.Logger + Probe Prober + Now func() time.Time + store atomic.Pointer[Store] + cursorKey []byte + sweepWake chan struct{} +} + +func New(c config.VODConfig, db *Store, logger *slog.Logger) *Service { + if logger == nil { + logger = slog.Default() + } + s := &Service{Config: c, Logger: logger, Probe: FFProbe{}, Now: func() time.Time { return time.Now().UTC() }} + s.sweepWake = make(chan struct{}, 1) + s.cursorKey = make([]byte, 32) + if _, err := rand.Read(s.cursorKey); err != nil { + panic(err) + } + s.store.Store(db) + return s +} +func (s *Service) SetStore(db *Store) { s.store.Store(db) } +func (s *Service) Path(j Job, kind string) string { + base := filepath.Join(s.Config.Root, j.ID.String()) + switch kind { + case "original": + return filepath.Join(base, "input.mp4") + case "result": + return filepath.Join(base, "output.mp4") + case "work": + return filepath.Join(base, "work") + default: + return filepath.Join(base, "output.mp4.part") + } +} +func (s *Service) Finish(j *Job, state string) { + now := s.Now() + j.Status = state + j.UpdatedAt = now + if j.CompletedAt == nil { + j.CompletedAt = &now + deadline := now.Add(s.Config.OriginalRetention) + j.OriginalDeleteAt = &deadline + } + if state == "done" { + j.Progress = 100 + if j.ResultExpiresAt == nil { + expiry := now.Add(s.Config.ResultRetention) + j.ResultExpiresAt = &expiry + } + } +} + +type View struct { + ID string `json:"job_id"` + Status string `json:"status"` + Progress int `json:"progress"` + QueuePosition *int `json:"queue_position"` + ETA *int64 `json:"eta_sec"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CompletedAt *time.Time `json:"completed_at"` + OriginalDeleteAt *time.Time `json:"original_delete_at"` + ResultExpiresAt *time.Time `json:"result_expires_at"` + Failure *Failure `json:"failure"` +} +type Failure struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (s *Service) View(j Job, queue []Job) View { + v := View{ID: j.ID.String(), Status: j.Status, Progress: j.Progress, CreatedAt: j.CreatedAt, UpdatedAt: j.UpdatedAt, CompletedAt: j.CompletedAt, OriginalDeleteAt: j.OriginalDeleteAt, ResultExpiresAt: j.ResultExpiresAt} + if j.ResultExpiresAt != nil && !s.Now().Before(*j.ResultExpiresAt) && j.Status == "done" { + v.Status = "expired" + } + if j.FailureCode != "" { + v.Failure = &Failure{Code: j.FailureCode, Message: "Video processing failed."} + } + var speed float64 + var samples int + for _, q := range queue { + if q.ProcessingFPS > 0 && q.UpdatedAt.After(s.Now().Add(-10*time.Minute)) { + speed += q.ProcessingFPS + samples++ + } + } + if samples > 0 { + speed /= float64(samples) + } + remaining := func(q Job) float64 { + if q.FramesTotal == 0 { + return -1 + } + return float64(q.FramesTotal - min(q.FramesDone, q.FramesTotal)) + } + if j.Status == "processing" && j.ProcessingFPS > 0 && remaining(j) >= 0 { + eta := int64(math.Ceil(remaining(j) / j.ProcessingFPS)) + v.ETA = &eta + } + if j.Status == "queued" || j.Status == "paused" { + position := 0 + work := float64(0) + known := speed > 0 + active := false + for _, q := range queue { + if q.Status == "processing" && q.DeletedAt == nil { + active = true + r := remaining(q) + if r < 0 { + known = false + } else { + work += r + } + } + } + for _, q := range queue { + if q.DeletedAt != nil || (q.Status != "queued" && q.Status != "paused") { + continue + } + position++ + r := remaining(q) + if r < 0 { + known = false + } else { + work += r + } + if q.ID == j.ID { + v.QueuePosition = &position + break + } + } + // If no worker is processing, live demand may make wait unbounded. + if known && active { + eta := int64(math.Ceil(work / speed)) + v.ETA = &eta + } + } + return v +} + +func (s *Service) WakeSweep() { + select { + case s.sweepWake <- struct{}{}: + default: + } +} From f9a5655ab2e1ceca273fe54f390d342f81b4652a Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:57:42 +0900 Subject: [PATCH 07/23] =?UTF-8?q?feat:=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20VOD=20=EB=B0=B0=EC=A0=95=EA=B3=BC=20?= =?UTF-8?q?=EC=B2=B4=ED=81=AC=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=9E=AC?= =?UTF-8?q?=EA=B0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ai/pool.go | 6 + internal/ai/vod.go | 68 ++++++ internal/vod/scheduler.go | 471 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 internal/ai/vod.go create mode 100644 internal/vod/scheduler.go diff --git a/internal/ai/pool.go b/internal/ai/pool.go index cb0f488..7edf9aa 100644 --- a/internal/ai/pool.go +++ b/internal/ai/pool.go @@ -18,6 +18,8 @@ import ( // 적은 대상에 배정한다. MPS(또는 소프트웨어 자원 상한)로 워커 프로세스마다 GPU // 몫이 정해져 있으므로, 세션을 워커에 나눠야 그 격리가 스트림별 격리가 된다. type Pool struct { + vod map[string]*VODLease + vodGPUs map[string]string clients []*Client logger *slog.Logger // timeout은 AddTarget이 새 클라이언트에 줄 프레임 타임아웃이다. @@ -228,6 +230,7 @@ func (p *Pool) AcquireMoveTarget(faceSwap bool, except, prefer string) (client * // 순서가 바뀌었을 수 있다. p.mu를 쥔 채 불린다. func (p *Pool) claimLocked(index int) (*Client, func()) { client := p.clients[index] + p.preemptVODLocked(client.Address()) p.adjust(index, 1) var once sync.Once release := func() { @@ -274,6 +277,7 @@ func (p *Pool) SetTargetReady(target string, ready bool) bool { p.unready = make(map[string]bool) } p.unready[target] = true + p.preemptVODLocked(target) } observer := p.readinessObserver p.mu.Unlock() @@ -394,6 +398,7 @@ func (p *Pool) RemoveTarget(address string) error { return errors.New("cannot remove the last AI target") } client := p.clients[index] + p.preemptVODLocked(address) p.clients = append(p.clients[:index:index], p.clients[index+1:]...) p.active = append(p.active[:index:index], p.active[index+1:]...) if p.next >= len(p.clients) { @@ -421,6 +426,7 @@ func (p *Pool) SetDraining(address string, draining bool) error { p.draining = make(map[string]bool) } p.draining[address] = true + p.preemptVODLocked(address) return nil } diff --git a/internal/ai/vod.go b/internal/ai/vod.go new file mode 100644 index 0000000..d0dae19 --- /dev/null +++ b/internal/ai/vod.go @@ -0,0 +1,68 @@ +package ai + +import ( + "sync" +) + +type VODLease struct { + Target string + GPU string + Preempt <-chan struct{} + signal chan struct{} + once sync.Once + pool *Pool +} + +func (l *VODLease) interrupt() { l.once.Do(func() { close(l.signal) }) } +func (l *VODLease) Release() { + l.pool.mu.Lock() + defer l.pool.mu.Unlock() + if l.pool.vod[l.GPU] == l { + delete(l.pool.vod, l.GPU) + } +} + +// ReserveVOD and live claim use the same mutex. VOD never changes live counters +// or ranking. Preemption is a channel close only: no RPC or I/O under pool.mu. +func (p *Pool) ReserveVOD(mapping map[string]string, freeMiB map[string]int64, minFree int64, limit int) *VODLease { + p.mu.Lock() + defer p.mu.Unlock() + if len(p.vod) >= limit { + return nil + } + for i, c := range p.clients { + gpu := mapping[c.Address()] + free, known := freeMiB[gpu] + if gpu == "" || !known || free < minFree || p.vod[gpu] != nil || p.active[i] > 0 || p.unready[c.Address()] || p.draining[c.Address()] || p.faceSwap[c.Address()] { + continue + } + busy := false + for k, other := range p.clients { + if mapping[other.Address()] == gpu && p.active[k] > 0 { + busy = true + break + } + } + if busy { + continue + } + ch := make(chan struct{}) + l := &VODLease{Target: c.Address(), GPU: gpu, signal: ch, Preempt: ch, pool: p} + if p.vod == nil { + p.vod = map[string]*VODLease{} + } + p.vod[gpu] = l + // Copy mapping so callers cannot race with the live hot path. + p.vodGPUs = make(map[string]string, len(mapping)) + for target, id := range mapping { + p.vodGPUs[target] = id + } + return l + } + return nil +} +func (p *Pool) preemptVODLocked(target string) { + if l := p.vod[p.vodGPUs[target]]; l != nil { + l.interrupt() + } +} diff --git a/internal/vod/scheduler.go b/internal/vod/scheduler.go new file mode 100644 index 0000000..60fd302 --- /dev/null +++ b/internal/vod/scheduler.go @@ -0,0 +1,471 @@ +package vod + +import ( + "context" + "database/sql" + "database/sql/driver" + "errors" + "fmt" + "math" + "os" + "sync" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + pb "inno-live-server/api/gen/aiv1" + "inno-live-server/internal/ai" +) + +// Engine has one PostgreSQL advisory-lock leader across server slots. Local pool +// live claims preempt leases without waiting for this scheduler or any RPC. +type Engine struct { + Service *Service + Pool *ai.Pool + Worker Worker + Memory MemoryProbe + mu sync.Mutex + running map[string]bool + wg sync.WaitGroup +} + +func NewEngine(s *Service, p *ai.Pool, w Worker, m MemoryProbe) *Engine { + return &Engine{Service: s, Pool: p, Worker: w, Memory: m, running: map[string]bool{}} +} + +const leaderKey int64 = 52120261026 + +func (e *Engine) Run(ctx context.Context) { + if !e.Service.Config.Enabled { + return + } + db := e.Service.store.Load() + if db == nil { + return + } + sqlDB, err := db.DB.DB() + if err != nil { + return + } + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + for ctx.Err() == nil { + conn, err := sqlDB.Conn(ctx) + if err == nil { + var locked bool + err = conn.QueryRowContext(ctx, "SELECT pg_try_advisory_lock($1)", leaderKey).Scan(&locked) + if err == nil && locked { + e.lead(ctx, conn) + } + // A connection with a possibly held advisory lock must not return to the pool. + if _, unlockErr := conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", leaderKey); unlockErr != nil { + _ = conn.Raw(func(any) error { return driver.ErrBadConn }) + } + _ = conn.Close() + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } +} +func (e *Engine) lead(parent context.Context, conn *sql.Conn) { + ctx, cancel := context.WithCancel(parent) + defer cancel() + defer func() { cancel(); e.wg.Wait() }() + e.wg.Add(1) + go func() { defer e.wg.Done(); e.Service.RunSweeper(ctx) }() + if e.Pool == nil { + <-ctx.Done() + return + } + // Resume only after every old worker has acknowledged pause/cancel. An + // unreachable process may still use the GPU, so takeover waits fail-closed. + if err := e.Reconcile(ctx); err != nil { + e.Service.Logger.Warn("vod reconciliation pending", "error", err) + return + } + tick := time.NewTicker(time.Second) + defer tick.Stop() + for { + check, c := context.WithTimeout(ctx, 3*time.Second) + err := conn.PingContext(check) + c() + if err != nil { + return + } + if err := e.Tick(ctx); err != nil { + e.Service.Logger.Warn("vod scheduling deferred", "error", err) + } + select { + case <-ctx.Done(): + return + case <-tick.C: + + } + } +} +func (e *Engine) Reconcile(ctx context.Context) error { + db := e.Service.store.Load() + jobs, err := db.All(ctx) + if err != nil { + return err + } + known := map[string]Job{} + for _, j := range jobs { + known[j.ID.String()] = j + } + for _, target := range e.Pool.Targets() { + if e.Service.Config.WorkerGPUs[target.Address] == "" { + continue + } + call, cancel := context.WithTimeout(ctx, 5*time.Second) + list, err := e.Worker.List(call, target.Address) + cancel() + if err != nil { + return err + } + for _, v := range list { + j, exists := known[v.JobId] + call, cancel := context.WithTimeout(ctx, 5*time.Second) + if !exists || j.DeletedAt != nil { + _, err = e.Worker.Cancel(call, target.Address, v.JobId) + } else if v.State == pb.VodJobState_VOD_JOB_STATE_PROCESSING { + v, err = e.Worker.Pause(call, target.Address, v.JobId) + } + cancel() + if err != nil { + return err + } + if exists && j.DeletedAt == nil { + j.WorkerTarget = target.Address + if err = e.apply(ctx, &j, v); err != nil { + return err + } + known[v.JobId] = j + } + } + } + // Explicitly query recorded targets, including ones removed from the pool. + for _, j := range known { + if j.WorkerTarget == "" { + continue + } + call, cancel := context.WithTimeout(ctx, 5*time.Second) + if j.DeletedAt != nil { + _, err = e.Worker.Cancel(call, j.WorkerTarget, j.ID.String()) + if err == nil { + j.WorkerTarget = "" + err = db.Save(ctx, &j) + } + } else { + var v *pb.VodJobStatus + v, err = e.Worker.Pause(call, j.WorkerTarget, j.ID.String()) + if err == nil { + err = e.apply(ctx, &j, v) + } else if status.Code(err) == codes.NotFound { + j.Status = "paused" + j.WorkerTarget = "" + j.UpdatedAt = e.Service.Now() + err = db.Save(ctx, &j) + } + } + cancel() + if err != nil { + return err + } + } + return nil +} +func (e *Engine) Tick(ctx context.Context) error { + db := e.Service.store.Load() + jobs, err := db.All(ctx) + if err != nil { + return err + } + for i := range jobs { + j := &jobs[i] + if j.Status == "uploaded" && j.DeletedAt == nil { + j.Status = "queued" + j.UpdatedAt = e.Service.Now() + if err := db.Save(ctx, j); err != nil && !errors.Is(err, ErrConflict) { + return err + } + } + } + // Cancel jobs deleted while no local runner exists (including queued jobs). + for _, j := range jobs { + if j.DeletedAt == nil || j.WorkerTarget == "" { + continue + } + e.mu.Lock() + running := e.running[j.ID.String()] + e.mu.Unlock() + if running { + continue + } + call, cancel := context.WithTimeout(ctx, 3*time.Second) + _, err := e.Worker.Cancel(call, j.WorkerTarget, j.ID.String()) + cancel() + if err == nil { + j.WorkerTarget = "" + if err = db.Save(ctx, &j); err != nil && !errors.Is(err, ErrConflict) { + return err + } + } + } + call, cancel := context.WithTimeout(ctx, 2*time.Second) + free, err := e.Memory.FreeMiB(call) + cancel() + if err != nil { + return err + } + for _, j := range jobs { + if j.DeletedAt != nil || (j.Status != "queued" && j.Status != "paused") { + continue + } + e.mu.Lock() + if e.running[j.ID.String()] { + e.mu.Unlock() + continue + } + lease := e.Pool.ReserveVOD(e.Service.Config.WorkerGPUs, free, e.Service.Config.MinFreeMiB, e.Service.Config.Concurrency) + if lease == nil { + e.mu.Unlock() + break + } + e.running[j.ID.String()] = true + e.wg.Add(1) + e.mu.Unlock() + go func(j Job) { + defer e.wg.Done() + defer lease.Release() + defer func() { e.mu.Lock(); delete(e.running, j.ID.String()); e.mu.Unlock() }() + e.runJob(ctx, j, lease) + }(j) + } + return nil +} +func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { + db := e.Service.store.Load() + // Persist target before admission: restart reconciliation can locate a job + // even if the Start response or the subsequent DB write was lost. + j.Status = "processing" + j.WorkerTarget = lease.Target + j.UpdatedAt = e.Service.Now() + if err := db.Save(ctx, &j); err != nil { + return + } + call, cancel := context.WithTimeout(ctx, 10*time.Second) + whitelist, err := e.Worker.Whitelist(call, "user:"+j.UserID.String()) + cancel() + if err != nil { + e.failUnstarted(ctx, &j) + return + } + select { + case <-lease.Preempt: + e.failUnstarted(ctx, &j) + return + default: + } + req := &pb.StartVodJobRequest{JobId: j.ID.String(), InputPath: e.Service.Path(j, "original"), OutputPath: e.Service.Path(j, "result"), WorkDir: e.Service.Path(j, "work"), WhitelistSource: &pb.StartVodJobRequest_Whitelist{Whitelist: whitelist}, AnonymizationMode: pb.FaceAnonymizationMode_FACE_ANONYMIZATION_MODE_BLUR, Resume: j.CheckpointToken != "", CheckpointToken: j.CheckpointToken, SegmentFrames: 300} + call, cancel = context.WithTimeout(ctx, 10*time.Second) + v, err := e.Worker.Start(call, lease.Target, req) + cancel() + if status.Code(err) == codes.ResourceExhausted || status.Code(err) == codes.FailedPrecondition { + if status.Code(err) == codes.FailedPrecondition { + e.Service.Finish(&j, "failed") + j.FailureCode = "processing_failed" + j.WorkerTarget = "" + _ = db.Save(ctx, &j) + } else { + e.failUnstarted(ctx, &j) + } + return + } + if err == nil { + if saveErr := e.apply(ctx, &j, v); saveErr == nil && j.Status != "processing" { + return + } + } + // Ambiguous admission errors are reconciled by Get/Pause; never start a second + // copy merely because the network lost the successful Start response. + watchCtx, stopWatch := context.WithCancel(ctx) + defer stopWatch() + updates := make(chan *pb.VodJobStatus, 8) + watchDone := make(chan error, 1) + jobID := j.ID.String() + go func(done chan<- error) { + done <- e.Worker.Watch(watchCtx, lease.Target, jobID, func(v *pb.VodJobStatus) error { + select { + case updates <- v: + return nil + case <-watchCtx.Done(): + return watchCtx.Err() + } + }) + }(watchDone) + defer func() { + stopWatch() + if watchDone != nil { + <-watchDone + } + }() + poll := time.NewTicker(500 * time.Millisecond) + defer poll.Stop() + preempt := lease.Preempt + yield := false + for { + select { + case <-ctx.Done(): + call, c := context.WithTimeout(context.Background(), 5*time.Second) + v, err := e.Worker.Pause(call, lease.Target, j.ID.String()) + if err == nil { + _ = e.refreshApply(call, &j, v) + } + c() + return + case <-preempt: + yield = true + preempt = nil + case v := <-updates: + if err := e.refreshApply(ctx, &j, v); err == nil && j.Status != "processing" { + return + } + case <-watchDone: + watchDone = nil + case <-poll.C: + } + current, err := db.Get(ctx, j.ID, j.UserID) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + // Get hides deleted jobs. Confirm the tombstone separately; DB outages do not + // authorize cancellation or filesystem deletion. + all, readErr := db.All(ctx) + if readErr != nil { + continue + } + found := false + for _, row := range all { + if row.ID == j.ID { + current = row + found = true + break + } + } + if !found { + continue + } + } + j = current + if j.DeletedAt != nil { + call, c := context.WithTimeout(ctx, 3*time.Second) + _, err = e.Worker.Cancel(call, lease.Target, j.ID.String()) + c() + if err == nil { + j.WorkerTarget = "" + _ = db.Save(ctx, &j) + return + } + continue + } + // Memory pressure yields existing work as well as preventing new admissions. + call, c := context.WithTimeout(ctx, 2*time.Second) + free, memErr := e.Memory.FreeMiB(call) + c() + if memErr != nil || free[lease.GPU] < e.Service.Config.MinFreeMiB { + yield = true + } + call, c = context.WithTimeout(ctx, 3*time.Second) + if yield { + v, err = e.Worker.Pause(call, lease.Target, j.ID.String()) + } else { + v, err = e.Worker.Get(call, lease.Target, j.ID.String()) + } + c() + if status.Code(err) == codes.NotFound { + j.Status = "paused" + j.WorkerTarget = "" + j.UpdatedAt = e.Service.Now() + _ = db.Save(ctx, &j) + return + } + if err == nil { + if err = e.refreshApply(ctx, &j, v); err == nil && j.Status != "processing" { + return + } + } + } +} +func (e *Engine) failUnstarted(ctx context.Context, j *Job) { + j.Status = "paused" + j.WorkerTarget = "" + j.UpdatedAt = e.Service.Now() + _ = e.Service.store.Load().Save(ctx, j) +} +func (e *Engine) refreshApply(ctx context.Context, j *Job, v *pb.VodJobStatus) error { + current, err := e.Service.store.Load().Get(ctx, j.ID, j.UserID) + if err != nil { + return err + } + *j = current + return e.apply(ctx, j, v) +} +func (e *Engine) apply(ctx context.Context, j *Job, v *pb.VodJobStatus) error { + if v == nil || v.JobId != j.ID.String() { + return fmt.Errorf("invalid VOD worker snapshot") + } + if j.DeletedAt != nil || j.Terminal() { + return nil + } + p := v.GetProgress() + if p != nil { + j.FramesDone = p.FramesDone + j.FramesTotal = p.FramesTotal + j.SegmentsDone = p.SegmentsDone + if p.ProcessingFps > 0 && !math.IsInf(p.ProcessingFps, 0) && !math.IsNaN(p.ProcessingFps) { + j.ProcessingFPS = p.ProcessingFps + } + if p.Fraction >= 0 && p.Fraction <= 1 { + j.Progress = max(j.Progress, min(99, int(p.Fraction*100))) + } + } + if v.CheckpointToken != "" { + j.CheckpointToken = v.CheckpointToken + } + switch v.State { + case pb.VodJobState_VOD_JOB_STATE_PROCESSING: + j.Status = "processing" + case pb.VodJobState_VOD_JOB_STATE_PAUSED: + j.Status = "paused" + j.WorkerTarget = "" + case pb.VodJobState_VOD_JOB_STATE_DONE: + info, err := os.Lstat(e.Service.Path(*j, "result")) + if err != nil || !info.Mode().IsRegular() || info.Size() == 0 { + e.Service.Finish(j, "failed") + j.FailureCode = "storage_failed" + } else { + e.Service.Finish(j, "done") + } + j.WorkerTarget = "" + case pb.VodJobState_VOD_JOB_STATE_FAILED: + e.Service.Finish(j, "failed") + j.FailureCode = "processing_failed" + j.WorkerTarget = "" + case pb.VodJobState_VOD_JOB_STATE_CANCELLED: + e.Service.Finish(j, "cancelled") + j.WorkerTarget = "" + default: + return fmt.Errorf("unknown VOD worker state") + } + j.UpdatedAt = e.Service.Now() + err := e.Service.store.Load().Save(ctx, j) + if err == nil && j.Terminal() { + e.Service.WakeSweep() + } + return err +} From dcd33eb0a17f736ebfa0ed1113fcd8b601abaea0 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:57:42 +0900 Subject: [PATCH 08/23] =?UTF-8?q?feat:=20=EC=9B=90=EB=B3=B8=20=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20=EB=B3=B4=EC=A1=B4=EA=B8=B0=EA=B0=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=EC=99=80=20=ED=8C=8C=EA=B8=B0=20=EA=B0=90=EC=82=AC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/vod/sweeper.go | 122 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 internal/vod/sweeper.go diff --git a/internal/vod/sweeper.go b/internal/vod/sweeper.go new file mode 100644 index 0000000..0b27e49 --- /dev/null +++ b/internal/vod/sweeper.go @@ -0,0 +1,122 @@ +package vod + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "errors" + "os" + "path/filepath" + "time" +) + +// Sweep removes originals and work immediately after acknowledged termination, +// leaving the configured deadline as an upper bound. Failed deletes retry on +// every sweep. Results remain until expiry; HTTP independently enforces expiry. +func (s *Service) Sweep(ctx context.Context) error { + if !s.Config.Enabled { + return nil + } + db := s.store.Load() + if db == nil { + return nil + } + jobs, err := db.All(ctx) + if err != nil { + return err + } + var errs []error + for _, j := range jobs { + if !j.Terminal() || j.WorkerTarget != "" { + continue + } + reason := "processing_completed" + if j.DeletedAt != nil { + reason = "user_deleted" + } else if j.Status == "failed" { + reason = "processing_failed" + } else if j.Status == "cancelled" { + reason = "cancelled" + } + clean := true + for _, kind := range []string{"original", "work", "partial"} { + if err := s.purge(ctx, j, kind, reason); err != nil { + errs = append(errs, err) + clean = false + } + } + expired := j.ResultExpiresAt != nil && !s.Now().Before(*j.ResultExpiresAt) + if expired || j.DeletedAt != nil || j.Status == "failed" || j.Status == "cancelled" { + resultReason := reason + if expired { + resultReason = "result_expired" + } + if err := s.purge(ctx, j, "result", resultReason); err != nil { + errs = append(errs, err) + clean = false + } + if clean && expired && j.DeletedAt == nil && j.Status == "done" { + j.Status = "expired" + j.UpdatedAt = s.Now() + if err := db.Save(ctx, &j); err != nil && !errors.Is(err, ErrConflict) { + errs = append(errs, err) + } + } + } + } + return errors.Join(errs...) +} +func (s *Service) purge(ctx context.Context, j Job, kind, reason string) error { + path := s.Path(j, kind) + // Only generated UUID paths are used. RemoveAll never follows symlinks. + if err := os.RemoveAll(path); err != nil { + return err + } + relative, err := filepath.Rel(s.Config.Root, path) + if err != nil { + return err + } + sum := sha256.Sum256([]byte(relative)) + entry := DeletionLog{JobID: j.ID, FileKind: kind, PathHash: hex.EncodeToString(sum[:]), DeletedAt: s.Now(), Reason: reason} + if err := s.store.Load().RecordDeletion(ctx, &entry); err != nil { + return err + } + if entry.ID != 0 { + s.Logger.Info("vod_file_destroyed", "job_id", j.ID, "file_kind", kind, "path_hash", entry.PathHash, "deleted_at", entry.DeletedAt, "reason", reason) + } + return nil +} + +func (s *Service) RunSweeper(ctx context.Context) { + if !s.Config.Enabled { + return + } + for ctx.Err() == nil { + if err := s.Sweep(ctx); err != nil { + s.Logger.Error("vod retention retry required", "error", err) + } + wait := s.Config.SweepInterval + if db := s.store.Load(); db != nil { + jobs, err := db.All(ctx) + if err == nil { + for _, j := range jobs { + if j.Status == "done" && j.ResultExpiresAt != nil { + remaining := j.ResultExpiresAt.Sub(s.Now()) + if remaining > 0 && remaining < wait { + wait = remaining + } + } + } + } + } + timer := time.NewTimer(wait) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-s.sweepWake: + timer.Stop() + case <-timer.C: + } + } +} From 4ab9b32c3bbc4bf5cd0a1f7753447597e6b6747f Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 17:57:42 +0900 Subject: [PATCH 09/23] =?UTF-8?q?feat:=20VOD=20=EB=9D=BC=EC=9A=B0=ED=8A=B8?= =?UTF-8?q?=EC=99=80=20=EB=B0=B1=EA=B7=B8=EB=9D=BC=EC=9A=B4=EB=93=9C=20?= =?UTF-8?q?=EC=8B=A4=ED=96=89=EC=9D=84=20=EB=B9=84=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?=ED=94=8C=EB=9E=98=EA=B7=B8=EB=A1=9C=20=EA=B2=A9=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/server/limits.go | 2 + internal/server/server.go | 10 ++++- internal/server/vod.go | 78 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 internal/server/vod.go diff --git a/internal/server/limits.go b/internal/server/limits.go index 357cea2..e541ca5 100644 --- a/internal/server/limits.go +++ b/internal/server/limits.go @@ -102,6 +102,8 @@ func broadcastRemaining(owner plan.Plan, onDevice bool, onAir, used time.Duratio // RunLimitEnforcer는 ctx가 끝날 때까지 주기적으로 한도를 집행한다. 원장·플랜 // 저장소가 조립되지 않은 배포(벤치·로컬)에서는 곧바로 돌아온다. func (s *Server) RunLimitEnforcer(ctx context.Context) { + stopVOD := s.startVOD(ctx) + defer stopVOD() if s.usageLedger == nil || s.plans == nil || s.sessions == nil { return } diff --git a/internal/server/server.go b/internal/server/server.go index c6afa4a..0e18bda 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -42,6 +42,7 @@ const maxBroadcastBody = 4 << 20 const platformCleanupTimeout = 10 * time.Second type Server struct { + vod *vodRuntime cfg config.Config logger *slog.Logger metrics *metrics.Registry @@ -111,6 +112,7 @@ func New( } mux := http.NewServeMux() s.mux = mux + s.configureVOD() mux.HandleFunc("GET /{$}", s.handleRoot) mux.HandleFunc("GET /health", s.handleHealth) mux.HandleFunc("GET /metrics", s.handleMetrics) @@ -226,6 +228,9 @@ func (s *Server) SetGuestQueue(queue *GuestQueue) { // guest 얼굴 정리가 끝날 때까지 기다린다. 세션별 작업에는 자체 10초 제한이 있고, // 전달받은 context는 shutdown 대기 시간만 제한한다. func (s *Server) WaitGuestCleanup(ctx context.Context) error { + if err := s.stopVOD(ctx); err != nil { + return err + } done := make(chan struct{}) go func() { s.guestCleanup.Wait() @@ -1427,6 +1432,9 @@ func recoverMiddleware(logger *slog.Logger, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if recovered := recover(); recovered != nil { + if recovered == http.ErrAbortHandler { + panic(recovered) + } logger.Error("HTTP handler panicked", "method", r.Method, "path", r.URL.Path, "panic", recovered) writeError(w, internalError()) } @@ -1513,7 +1521,7 @@ func corsMiddleware(logger *slog.Logger, origins origin.Config, next http.Handle if allowCredentials && allowedOrigin != "*" { w.Header().Set("Access-Control-Allow-Credentials", "true") } - w.Header().Set("Access-Control-Expose-Headers", "X-Request-ID") + w.Header().Set("Access-Control-Expose-Headers", "X-Request-ID, Location, Content-Disposition, Content-Length, Retry-After") if allowedOrigin != "*" { w.Header().Add("Vary", "Origin") } diff --git a/internal/server/vod.go b/internal/server/vod.go new file mode 100644 index 0000000..6ee3208 --- /dev/null +++ b/internal/server/vod.go @@ -0,0 +1,78 @@ +package server + +import ( + "context" + "net/http" + "sync" + + "inno-live-server/internal/database" + "inno-live-server/internal/vod" +) + +// This lifecycle joins the existing server background context and shutdown hook +// without changing the deployment/session-handoff entrypoint from PR #519. +type vodRuntime struct { + service *vod.Service + mu sync.Mutex + cancel context.CancelFunc + done chan struct{} + stopped bool +} + +func (s *Server) configureVOD() { + if !s.cfg.VOD.Enabled { + return + } + service := vod.New(s.cfg.VOD, nil, s.logger) + s.vod = &vodRuntime{service: service} + service.Register(s.mux, func(h http.Handler) http.Handler { return s.requireUser(s.withUserOperation(h)) }) +} +func (s *Server) startVOD(ctx context.Context) func() { + if !s.cfg.VOD.Enabled || s.vod == nil { + return func() {} + } + runtime := s.vod + runtime.mu.Lock() + if runtime.stopped || runtime.done != nil { + runtime.mu.Unlock() + return func() {} + } + child, cancel := context.WithCancel(ctx) + runtime.cancel = cancel + runtime.done = make(chan struct{}) + done := runtime.done + runtime.mu.Unlock() + go func() { + defer close(done) + conn, err := database.Open(child, database.Options{URL: s.cfg.DatabaseURL, MaxOpenConns: s.cfg.DatabaseMaxOpenConns, MaxIdleConns: s.cfg.DatabaseMaxIdleConns, ConnMaxLifetime: s.cfg.DatabaseConnMaxLifetime, ConnMaxIdleTime: s.cfg.DatabaseConnMaxIdleTime}) + if err != nil { + s.logger.Error("vod database unavailable") + return + } + defer conn.Close() + runtime.service.SetStore(vod.NewStore(conn.DB)) + defer runtime.service.SetStore(nil) + engine := vod.NewEngine(runtime.service, s.ai, vod.GRPCWorker{Pool: s.ai}, vod.NvidiaMemory{}) + engine.Run(child) + }() + return cancel +} +func (s *Server) stopVOD(ctx context.Context) error { + if s.vod == nil { + return nil + } + s.vod.mu.Lock() + s.vod.stopped = true + cancel, done := s.vod.cancel, s.vod.done + s.vod.mu.Unlock() + if cancel == nil { + return nil + } + cancel() + select { + case <-done: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} From df3aeefb7dd210f520010b2c9e4b519b4bef0b39 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:14:05 +0900 Subject: [PATCH 10/23] =?UTF-8?q?fix:=20VOD=20=EB=9D=BC=EC=9D=B4=EB=B8=8C?= =?UTF-8?q?=20=EC=96=91=EB=B3=B4=EC=99=80=20=ED=8C=8C=EA=B8=B0=20=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=EB=B0=8F=20=EB=8B=A4=EC=9A=B4=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A4=91=EB=8B=A8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ai/vod.go | 11 +++++ internal/vod/http.go | 25 ++++++++-- internal/vod/scheduler.go | 100 +++++++++++++++++++++++++++++++++++--- internal/vod/sweeper.go | 91 +++++++++++++++++++++++++++++++--- internal/vod/worker.go | 18 +++++++ 5 files changed, 228 insertions(+), 17 deletions(-) diff --git a/internal/ai/vod.go b/internal/ai/vod.go index d0dae19..db22c95 100644 --- a/internal/ai/vod.go +++ b/internal/ai/vod.go @@ -27,6 +27,11 @@ func (l *VODLease) Release() { func (p *Pool) ReserveVOD(mapping map[string]string, freeMiB map[string]int64, minFree int64, limit int) *VODLease { p.mu.Lock() defer p.mu.Unlock() + for _, c := range p.clients { + if mapping[c.Address()] == "" { + return nil + } + } if len(p.vod) >= limit { return nil } @@ -62,6 +67,12 @@ func (p *Pool) ReserveVOD(mapping map[string]string, freeMiB map[string]int64, m return nil } func (p *Pool) preemptVODLocked(target string) { + if p.vodGPUs[target] == "" { + for _, l := range p.vod { + l.interrupt() + } + return + } if l := p.vod[p.vodGPUs[target]]; l != nil { l.interrupt() } diff --git a/internal/vod/http.go b/internal/vod/http.go index 98aa14a..70327f1 100644 --- a/internal/vod/http.go +++ b/internal/vod/http.go @@ -351,18 +351,28 @@ func (s *Service) result(w http.ResponseWriter, r *http.Request) { fail(w, 409, "result_not_ready") return } - path := s.Path(j, "result") - info, e := os.Lstat(path) - if e != nil || !info.Mode().IsRegular() { + root, e := os.OpenRoot(s.Config.Root) + if e != nil { + fail(w, 503, "service_unavailable") + return + } + defer root.Close() + relative, e := filepath.Rel(s.Config.Root, s.Path(j, "result")) + if e != nil { fail(w, 503, "service_unavailable") return } - f, e := os.Open(path) + f, e := root.Open(relative) if e != nil { fail(w, 503, "service_unavailable") return } defer f.Close() + info, e := f.Stat() + if e != nil || !info.Mode().IsRegular() { + fail(w, 503, "service_unavailable") + return + } rc := http.NewResponseController(w) _ = rc.SetWriteDeadline(time.Now().Add(j.ResultExpiresAt.Sub(s.Now()))) defer func() { _ = rc.SetWriteDeadline(time.Time{}) }() @@ -398,6 +408,13 @@ func (s *Service) result(w http.ResponseWriter, r *http.Request) { if ctx.Err() != nil || !s.Now().Before(*j.ResultExpiresAt) { panic(http.ErrAbortHandler) } + // Re-authorize each chunk across server slots. The watcher above also + // interrupts a write blocked on a slow client; polling alone could let + // a fast transfer finish after DELETE before the next watcher tick. + current, checkErr := db.Get(ctx, j.ID, j.UserID) + if checkErr != nil || current.Status != "done" { + panic(http.ErrAbortHandler) + } n, e := f.Read(buf) if n > 0 { if _, we := w.Write(buf[:n]); we != nil { diff --git a/internal/vod/scheduler.go b/internal/vod/scheduler.go index 60fd302..fefda3b 100644 --- a/internal/vod/scheduler.go +++ b/internal/vod/scheduler.go @@ -58,9 +58,11 @@ func (e *Engine) Run(ctx context.Context) { e.lead(ctx, conn) } // A connection with a possibly held advisory lock must not return to the pool. - if _, unlockErr := conn.ExecContext(context.Background(), "SELECT pg_advisory_unlock($1)", leaderKey); unlockErr != nil { + unlockCtx, unlockCancel := context.WithTimeout(context.Background(), 3*time.Second) + if _, unlockErr := conn.ExecContext(unlockCtx, "SELECT pg_advisory_unlock($1)", leaderKey); unlockErr != nil { _ = conn.Raw(func(any) error { return driver.ErrBadConn }) } + unlockCancel() _ = conn.Close() } select { @@ -216,7 +218,7 @@ func (e *Engine) Tick(ctx context.Context) error { } } call, cancel := context.WithTimeout(ctx, 2*time.Second) - free, err := e.Memory.FreeMiB(call) + free, err := e.availableMemory(call) cancel() if err != nil { return err @@ -235,6 +237,15 @@ func (e *Engine) Tick(ctx context.Context) error { e.mu.Unlock() break } + if j.Status == "paused" { + j.Status = "queued" + j.UpdatedAt = e.Service.Now() + if err := db.Save(ctx, &j); err != nil { + lease.Release() + e.mu.Unlock() + continue + } + } e.running[j.ID.String()] = true e.wg.Add(1) e.mu.Unlock() @@ -248,6 +259,15 @@ func (e *Engine) Tick(ctx context.Context) error { return nil } func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { + // This control path remains responsive even if Start, a database write or + // telemetry is slow. Pause is idempotent; snapshots are persisted by the + // single runner below. A NotFound during admission is retried until Start + // has actually admitted the job or the runner exits. + interruptCtx, stopInterrupt := context.WithCancel(ctx) + interruptDone := make(chan struct{}) + go func(id string) { defer close(interruptDone); e.preempt(interruptCtx, lease, id) }(j.ID.String()) + defer func() { stopInterrupt(); <-interruptDone }() + db := e.Service.store.Load() // Persist target before admission: restart reconciliation can locate a job // even if the Start response or the subsequent DB write was lost. @@ -274,8 +294,8 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { call, cancel = context.WithTimeout(ctx, 10*time.Second) v, err := e.Worker.Start(call, lease.Target, req) cancel() - if status.Code(err) == codes.ResourceExhausted || status.Code(err) == codes.FailedPrecondition { - if status.Code(err) == codes.FailedPrecondition { + if status.Code(err) == codes.ResourceExhausted || status.Code(err) == codes.FailedPrecondition || status.Code(err) == codes.InvalidArgument || status.Code(err) == codes.Unimplemented { + if status.Code(err) != codes.ResourceExhausted { e.Service.Finish(&j, "failed") j.FailureCode = "processing_failed" j.WorkerTarget = "" @@ -338,6 +358,19 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { watchDone = nil case <-poll.C: } + + // Live preemption reaches the worker even while the database or GPU + // telemetry is unavailable. Neither is on the pause control path. + if yield { + call, c := context.WithTimeout(ctx, 3*time.Second) + paused, pauseErr := e.Worker.Pause(call, lease.Target, jobID) + c() + if pauseErr == nil { + if err := e.refreshApply(ctx, &j, paused); err == nil && j.Status != "processing" { + return + } + } + } current, err := db.Get(ctx, j.ID, j.UserID) if errors.Is(err, sql.ErrNoRows) { continue @@ -373,9 +406,12 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { } continue } + if yield { + continue + } // Memory pressure yields existing work as well as preventing new admissions. call, c := context.WithTimeout(ctx, 2*time.Second) - free, memErr := e.Memory.FreeMiB(call) + free, memErr := e.availableMemory(call) c() if memErr != nil || free[lease.GPU] < e.Service.Config.MinFreeMiB { yield = true @@ -430,8 +466,12 @@ func (e *Engine) apply(ctx context.Context, j *Job, v *pb.VodJobStatus) error { if p.ProcessingFps > 0 && !math.IsInf(p.ProcessingFps, 0) && !math.IsNaN(p.ProcessingFps) { j.ProcessingFPS = p.ProcessingFps } - if p.Fraction >= 0 && p.Fraction <= 1 { - j.Progress = max(j.Progress, min(99, int(p.Fraction*100))) + // API progress counts durable segments, unlike the worker fraction which + // also includes the current, discardable segment. Keep the high-water + // mark across estimated frame count changes and checkpoint replay. + if p.FramesTotal > 0 { + committed := min(uint64(p.SegmentsDone)*300, p.FramesTotal) + j.Progress = max(j.Progress, min(99, int(float64(committed)*100/float64(p.FramesTotal)))) } } if v.CheckpointToken != "" { @@ -469,3 +509,49 @@ func (e *Engine) apply(ctx context.Context, j *Job, v *pb.VodJobStatus) error { } return err } + +func (e *Engine) availableMemory(ctx context.Context) (map[string]int64, error) { + free, err := e.Memory.FreeMiB(ctx) + if err != nil { + return nil, err + } + if free == nil { + free = map[string]int64{} + } + // A new unmapped replacement slot may share any GPU. Suspend VOD until + // operators provide its physical identity; live routing remains unaffected. + for _, target := range e.Pool.Targets() { + gpu := e.Service.Config.WorkerGPUs[target.Address] + if gpu == "" { + return nil, fmt.Errorf("missing VOD GPU mapping") + } + live, err := e.Worker.LiveStreams(ctx, target.Address) + if err != nil || live > 0 { + free[gpu] = -1 + } + } + return free, nil +} + +func (e *Engine) preempt(ctx context.Context, lease *ai.VODLease, id string) { + select { + case <-ctx.Done(): + return + case <-lease.Preempt: + } + for ctx.Err() == nil { + call, cancel := context.WithTimeout(ctx, 3*time.Second) + v, err := e.Worker.Pause(call, lease.Target, id) + cancel() + if err == nil && v != nil && v.State != pb.VodJobState_VOD_JOB_STATE_PROCESSING && v.State != pb.VodJobState_VOD_JOB_STATE_UNSPECIFIED { + return + } + timer := time.NewTimer(100 * time.Millisecond) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + } +} diff --git a/internal/vod/sweeper.go b/internal/vod/sweeper.go index 0b27e49..932ea59 100644 --- a/internal/vod/sweeper.go +++ b/internal/vod/sweeper.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" "errors" + "github.com/google/uuid" "os" "path/filepath" "time" @@ -27,9 +28,18 @@ func (s *Service) Sweep(ctx context.Context) error { } var errs []error for _, j := range jobs { - if !j.Terminal() || j.WorkerTarget != "" { + if !j.Terminal() { continue } + if j.WorkerTarget != "" { + if j.OriginalDeleteAt == nil || s.Now().Before(*j.OriginalDeleteAt) { + continue + } + // A failed worker acknowledgement cannot extend the promised + // filesystem retention deadline. Keep retrying cancellation, but + // remove its server-owned paths now and report the missing ack. + s.Logger.Error("vod_retention_worker_unconfirmed", "job_id", j.ID, "deadline", *j.OriginalDeleteAt) + } reason := "processing_completed" if j.DeletedAt != nil { reason = "user_deleted" @@ -64,15 +74,27 @@ func (s *Service) Sweep(ctx context.Context) error { } } } + if err := s.sweepOrphans(ctx, jobs); err != nil { + errs = append(errs, err) + } return errors.Join(errs...) } func (s *Service) purge(ctx context.Context, j Job, kind, reason string) error { path := s.Path(j, kind) - // Only generated UUID paths are used. RemoveAll never follows symlinks. - if err := os.RemoveAll(path); err != nil { + relative, err := filepath.Rel(s.Config.Root, path) + if err != nil { return err } - relative, err := filepath.Rel(s.Config.Root, path) + // OpenRoot also rejects a job directory symlink escaping the shared root. + root, err := os.OpenRoot(s.Config.Root) + if err != nil { + return err + } + defer root.Close() + if err := root.RemoveAll(relative); err != nil { + return err + } + relative, err = filepath.Rel(s.Config.Root, path) if err != nil { return err } @@ -100,8 +122,11 @@ func (s *Service) RunSweeper(ctx context.Context) { jobs, err := db.All(ctx) if err == nil { for _, j := range jobs { - if j.Status == "done" && j.ResultExpiresAt != nil { - remaining := j.ResultExpiresAt.Sub(s.Now()) + for _, deadline := range []*time.Time{j.OriginalDeleteAt, j.ResultExpiresAt} { + if deadline == nil { + continue + } + remaining := deadline.Sub(s.Now()) if remaining > 0 && remaining < wait { wait = remaining } @@ -120,3 +145,57 @@ func (s *Service) RunSweeper(ctx context.Context) { } } } + +// A process can die after writing input but before accepting a DB row. Only UUID +// directories older than the entire upload deadline plus one sweep are eligible. +func (s *Service) sweepOrphans(ctx context.Context, jobs []Job) error { + entries, err := os.ReadDir(s.Config.Root) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return err + } + known := map[uuid.UUID]bool{} + for _, j := range jobs { + known[j.ID] = true + } + for _, entry := range entries { + id, err := uuid.Parse(entry.Name()) + if err != nil || known[id] || !entry.IsDir() { + continue + } + info, err := entry.Info() + if err != nil { + return err + } + if s.Now().Sub(info.ModTime()) <= s.Config.UploadTimeout+s.Config.SweepInterval { + continue + } + // Re-read after the directory scan so completed uploads do not become orphans + // merely because they committed after the initial job snapshot. + var count int64 + if err := s.store.Load().DB.WithContext(ctx).Model(&Job{}).Where("id = ?", id).Count(&count).Error; err != nil { + return err + } + if count != 0 { + continue + } + j := Job{ID: id} + for _, kind := range []string{"original", "work", "partial", "result"} { + if err := s.purge(ctx, j, kind, "abandoned_upload"); err != nil { + return err + } + } + root, err := os.OpenRoot(s.Config.Root) + if err != nil { + return err + } + err = root.Remove(entry.Name()) + _ = root.Close() + if err != nil { + return err + } + } + return nil +} diff --git a/internal/vod/worker.go b/internal/vod/worker.go index 9c40903..206ecbd 100644 --- a/internal/vod/worker.go +++ b/internal/vod/worker.go @@ -17,6 +17,7 @@ import ( // Worker is the interface-A contract. Callers own deadlines, cancellation and // checkpoint persistence. Whitelist embeddings are never written to disk. type Worker interface { + LiveStreams(context.Context, string) (uint64, error) Start(context.Context, string, *pb.StartVodJobRequest) (*pb.VodJobStatus, error) Watch(context.Context, string, string, func(*pb.VodJobStatus) error) error Get(context.Context, string, string) (*pb.VodJobStatus, error) @@ -135,3 +136,20 @@ func (NvidiaMemory) FreeMiB(ctx context.Context) (map[string]int64, error) { } return result, nil } + +// ListSessions observes live streams from other server slots as well as this pool. +func (w GRPCWorker) LiveStreams(ctx context.Context, target string) (uint64, error) { + c := w.Pool.Target(target) + if c == nil { + return 0, status.Error(codes.Unavailable, "worker removed") + } + response, err := c.ListSessions(ctx) + if err != nil { + return 0, err + } + var count uint64 + for _, session := range response.Sessions { + count += uint64(session.ActiveStreamCount) + } + return count, nil +} From b5a7b6359f52b67add088209339b10381f315ff2 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:14:05 +0900 Subject: [PATCH 11/23] =?UTF-8?q?test:=20VOD=20API=20=EC=8A=A4=EC=BC=80?= =?UTF-8?q?=EC=A4=84=EB=9F=AC=20=EB=B3=B4=EC=A1=B4=EA=B8=B0=EA=B0=84?= =?UTF-8?q?=EA=B3=BC=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/ai/vod_test.go | 132 +++++++++ internal/config/vod_test.go | 30 ++ internal/server/vod_test.go | 51 ++++ internal/vod/scheduler_test.go | 350 ++++++++++++++++++++++ internal/vod/vod_test.go | 518 +++++++++++++++++++++++++++++++++ internal/vod/worker_test.go | 117 ++++++++ 6 files changed, 1198 insertions(+) create mode 100644 internal/ai/vod_test.go create mode 100644 internal/config/vod_test.go create mode 100644 internal/server/vod_test.go create mode 100644 internal/vod/scheduler_test.go create mode 100644 internal/vod/vod_test.go create mode 100644 internal/vod/worker_test.go diff --git a/internal/ai/vod_test.go b/internal/ai/vod_test.go new file mode 100644 index 0000000..0094d0d --- /dev/null +++ b/internal/ai/vod_test.go @@ -0,0 +1,132 @@ +package ai + +import ( + "testing" + "time" +) + +func TestVODLiveAcquireRemainsImmediate(t *testing.T) { + p, err := NewPool([]string{"worker-a:5000", "worker-b:5000"}, time.Second) + if err != nil { + t.Fatal(err) + } + defer p.Close() + mapping := map[string]string{"worker-a:5000": "GPU-0", "worker-b:5000": "GPU-0"} + l := p.ReserveVOD(mapping, map[string]int64{"GPU-0": 8192}, 4096, 2) + if l == nil { + t.Fatal("no VOD lease") + } + defer l.Release() + // No consumer acknowledges preemption. Live must still return immediately, + // with exactly the same round-robin choice and active count as an empty pool. + done := make(chan struct{}) + go func() { + c, release := p.Acquire(false) + if c.Address() != "worker-a:5000" { + t.Errorf("live ranking changed: %s", c.Address()) + } + release() + close(done) + }() + select { + case <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("live waited for VOD") + } + select { + case <-l.Preempt: + default: + t.Fatal("missing preemption") + } + c, release := p.Acquire(false) + defer release() + if c.Address() != "worker-b:5000" { + t.Fatal("round robin changed") + } +} +func TestVODGPUAdmissionAndLiveMovePreemption(t *testing.T) { + p, _ := NewPool([]string{"a:1", "b:1", "c:1"}, time.Second) + defer p.Close() + mapping := map[string]string{"a:1": "GPU-0", "b:1": "GPU-0", "c:1": "GPU-1"} + free := map[string]int64{"GPU-0": 8192, "GPU-1": 8192} + first := p.ReserveVOD(mapping, free, 4096, 2) + if first == nil { + t.Fatal("first") + } + defer first.Release() + second := p.ReserveVOD(mapping, free, 4096, 2) + if second == nil || second.GPU == first.GPU { + t.Fatal("per-GPU cap") + } + defer second.Release() + if p.ReserveVOD(mapping, free, 4096, 2) != nil { + t.Fatal("global cap") + } + _, release, ok := p.AcquireMoveTarget(false, "c:1", "b:1") + if !ok { + t.Fatal("move rejected") + } + defer release() + select { + case <-first.Preempt: + default: + t.Fatal("same GPU sibling did not preempt") + } + first.Release() + if p.ReserveVOD(mapping, free, 4096, 3) != nil { + t.Fatal("VOD admitted beside live") + } +} +func TestVODUnknownMemoryAndDrainingFailClosed(t *testing.T) { + p, _ := NewPool([]string{"a:1"}, time.Second) + defer p.Close() + mapping := map[string]string{"a:1": "GPU-0"} + for _, free := range []map[string]int64{nil, {"GPU-0": 4095}} { + if p.ReserveVOD(mapping, free, 4096, 1) != nil { + t.Fatal("unsafe memory admission") + } + } + l := p.ReserveVOD(mapping, map[string]int64{"GPU-0": 4096}, 4096, 1) + if l == nil { + t.Fatal("memory boundary") + } + defer l.Release() + if err := p.SetDraining("a:1", true); err != nil { + t.Fatal(err) + } + select { + case <-l.Preempt: + default: + t.Fatal("drain did not preempt") + } +} + +func TestVODUnmappedReplacementStopsAdmission(t *testing.T) { + p, _ := NewPool([]string{"a:1"}, time.Second) + defer p.Close() + mapping := map[string]string{"a:1": "GPU-0"} + free := map[string]int64{"GPU-0": 8192} + l := p.ReserveVOD(mapping, free, 4096, 1) + if l == nil { + t.Fatal("lease") + } + defer l.Release() + if _, err := p.AddTarget("b:1", false); err != nil { + t.Fatal(err) + } + _ = p.SetDraining("b:1", false) + _, release, ok := p.AcquireMoveTarget(false, "a:1", "b:1") + if !ok { + t.Fatal("live replacement blocked") + } + defer release() + select { + case <-l.Preempt: + default: + t.Fatal("unknown physical GPU did not yield") + } + l.Release() + if p.ReserveVOD(mapping, free, 4096, 1) != nil { + t.Fatal("incomplete GPU topology admitted") + } +} diff --git a/internal/config/vod_test.go b/internal/config/vod_test.go new file mode 100644 index 0000000..de92098 --- /dev/null +++ b/internal/config/vod_test.go @@ -0,0 +1,30 @@ +package config + +import ( + "testing" + "time" +) + +func TestVODDefaultsDisabled(t *testing.T) { + t.Setenv("VOD_ENABLED", "") + c, e := loadVOD() + if e != nil || c.Enabled || c.MaxUploadBytes != 2147483648 || c.MaxDuration != time.Hour || c.ResultRetention != 7*24*time.Hour || c.OriginalRetention != 24*time.Hour || c.Concurrency != 1 || c.MinFreeMiB != 4096 { + t.Fatalf("defaults: %+v, %v", c, e) + } +} +func TestVODEnabledValidation(t *testing.T) { + t.Setenv("VOD_ENABLED", "true") + t.Setenv("VOD_WORKER_GPUS", "a:1=GPU-0,b:1=GPU-0") + c, e := loadVOD() + if e != nil || c.WorkerGPUs["a:1"] != "GPU-0" { + t.Fatal(e) + } + for key, value := range map[string]string{"VOD_MAX_UPLOAD_BYTES": "0", "VOD_ORIGINAL_RETENTION_SEC": "86401", "VOD_RESULT_RETENTION_SEC": "0", "VOD_SWEEP_INTERVAL": "0s", "VOD_ROOT": "relative", "VOD_WORKER_GPUS": "unknown", "VOD_MAX_CONCURRENCY": "bad"} { + t.Run(key, func(t *testing.T) { + t.Setenv(key, value) + if _, e := loadVOD(); e == nil { + t.Fatal("invalid setting accepted") + } + }) + } +} diff --git a/internal/server/vod_test.go b/internal/server/vod_test.go new file mode 100644 index 0000000..5223287 --- /dev/null +++ b/internal/server/vod_test.go @@ -0,0 +1,51 @@ +package server + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" + + "inno-live-server/internal/config" +) + +func TestVODDisabledNoRoutesOrBackgroundLifecycle(t *testing.T) { + app, manager := newTestApplication(t) + defer manager.CloseAll() + if app.vod != nil { + t.Fatal("disabled runtime allocated") + } + stop := app.startVOD(context.Background()) + stop() + if app.vod != nil { + t.Fatal("disabled background started") + } + for _, path := range []string{"/vod/jobs", "/api/v1/vod/jobs"} { + w := httptest.NewRecorder() + app.Handler().ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != 404 { + t.Fatal(path, w.Code) + } + } +} +func TestVODEnabledUsesExistingUserMiddleware(t *testing.T) { + app, manager := newTestApplication(t) + defer manager.CloseAll() + app.cfg.VOD = config.DefaultVOD() + app.cfg.VOD.Enabled = true + app.configureVOD() + w := httptest.NewRecorder() + app.Handler().ServeHTTP(w, httptest.NewRequest("GET", "/vod/jobs", nil)) + if w.Code != 401 { + t.Fatal(w.Code) + } +} +func TestRecoverMiddlewarePropagatesStreamAbort(t *testing.T) { + defer func() { + if v := recover(); v != http.ErrAbortHandler { + t.Fatalf("abort swallowed: %v", v) + } + }() + h := recoverMiddleware(nil, http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic(http.ErrAbortHandler) })) + h.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest("GET", "/", nil)) +} diff --git a/internal/vod/scheduler_test.go b/internal/vod/scheduler_test.go new file mode 100644 index 0000000..b8839cf --- /dev/null +++ b/internal/vod/scheduler_test.go @@ -0,0 +1,350 @@ +package vod + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + pb "inno-live-server/api/gen/aiv1" + "inno-live-server/internal/ai" +) + +type fakeMemory struct{} + +func (fakeMemory) FreeMiB(context.Context) (map[string]int64, error) { + return map[string]int64{"GPU-0": 8192, "GPU-1": 8192}, nil +} + +type fakeWorker struct { + mu sync.Mutex + jobs map[string]*pb.VodJobStatus + starts []*pb.StartVodJobRequest + pauseGate <-chan struct{} + startErr error + remoteLive map[string]uint64 +} + +func newFakeWorker() *fakeWorker { return &fakeWorker{jobs: map[string]*pb.VodJobStatus{}} } +func (f *fakeWorker) Start(_ context.Context, target string, r *pb.StartVodJobRequest) (*pb.VodJobStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.startErr != nil { + return nil, f.startErr + } + f.starts = append(f.starts, proto.Clone(r).(*pb.StartVodJobRequest)) + v := &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PROCESSING, CheckpointToken: r.CheckpointToken, Progress: &pb.VodProgress{FramesDone: 300, FramesTotal: 1800, Fraction: 1.0 / 6, ProcessingFps: 30}, WorkerTarget: target} + f.jobs[r.JobId] = v + return proto.Clone(v).(*pb.VodJobStatus), nil +} +func (f *fakeWorker) Get(_ context.Context, _ string, id string) (*pb.VodJobStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + v := f.jobs[id] + if v == nil { + return nil, status.Error(codes.NotFound, "unknown") + } + return proto.Clone(v).(*pb.VodJobStatus), nil +} +func (f *fakeWorker) Watch(ctx context.Context, _ string, _ string, _ func(*pb.VodJobStatus) error) error { + <-ctx.Done() + return ctx.Err() +} +func (f *fakeWorker) Pause(ctx context.Context, _ string, id string) (*pb.VodJobStatus, error) { + if f.pauseGate != nil { + select { + case <-f.pauseGate: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + f.mu.Lock() + defer f.mu.Unlock() + v := f.jobs[id] + if v == nil { + return nil, status.Error(codes.NotFound, "unknown") + } + v.State = pb.VodJobState_VOD_JOB_STATE_PAUSED + v.CheckpointToken = "opaque-checkpoint-300" + v.Progress.SegmentsDone = 1 + return proto.Clone(v).(*pb.VodJobStatus), nil +} +func (f *fakeWorker) Cancel(_ context.Context, _ string, id string) (*pb.VodJobStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + v := &pb.VodJobStatus{JobId: id, State: pb.VodJobState_VOD_JOB_STATE_CANCELLED} + f.jobs[id] = v + return proto.Clone(v).(*pb.VodJobStatus), nil +} +func (f *fakeWorker) List(_ context.Context, target string) ([]*pb.VodJobStatus, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []*pb.VodJobStatus + for _, v := range f.jobs { + if v.WorkerTarget == target { + out = append(out, proto.Clone(v).(*pb.VodJobStatus)) + } + } + return out, nil +} +func (f *fakeWorker) Whitelist(context.Context, string) (*pb.VodWhitelist, error) { + return &pb.VodWhitelist{EmbeddingModel: "test-model", Entries: []*pb.WhitelistEmbedding{{EntryId: "registered-person", Values: []float32{1, 0}}}}, nil +} +func eventually(t *testing.T, check func() bool) { + t.Helper() + until := time.Now().Add(6 * time.Second) + for time.Now().Before(until) { + if check() { + return + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("condition timed out") +} +func setupEngine(t *testing.T) (*Service, *Engine, *fakeWorker, context.Context) { + t.Helper() + s := testService(t) + p, _ := ai.NewPool([]string{"a:1", "b:1", "c:1"}, time.Second) + s.Config.WorkerGPUs = map[string]string{"a:1": "GPU-0", "b:1": "GPU-0", "c:1": "GPU-1"} + f := newFakeWorker() + e := NewEngine(s, p, f, fakeMemory{}) + ctx, cancel := context.WithCancel(context.Background()) + t.Cleanup(func() { cancel(); e.wg.Wait(); _ = p.Close() }) + return s, e, f, ctx +} +func TestSchedulerLivePreemptionCheckpointResume(t *testing.T) { + s, e, f, ctx := setupEngine(t) + gate := make(chan struct{}) + f.pauseGate = gate + j := createJob(t, s, "uploaded") + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return len(f.starts) == 1 }) + done := make(chan func(), 1) + go func() { _, release := e.Pool.Acquire(false); done <- release }() + var release func() + select { + case release = <-done: + case <-time.After(200 * time.Millisecond): + t.Fatal("live blocked on Pause RPC") + } + close(gate) + eventually(t, func() bool { + current, _ := s.store.Load().Get(ctx, j.ID, j.UserID) + return current.Status == "paused" && current.CheckpointToken == "opaque-checkpoint-300" && current.SegmentsDone == 1 + }) + release() + eventually(t, func() bool { e.mu.Lock(); defer e.mu.Unlock(); return len(e.running) == 0 }) + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return len(f.starts) == 2 }) + f.mu.Lock() + last := f.starts[1] + f.mu.Unlock() + if !last.Resume || last.CheckpointToken != "opaque-checkpoint-300" || len(last.GetWhitelist().Entries) != 1 { + t.Fatalf("resume lost checkpoint or whitelist: %v", last) + } +} +func TestSchedulerGlobalAndGPUConcurrency(t *testing.T) { + s, e, f, ctx := setupEngine(t) + s.Config.Concurrency = 2 + for range 4 { + createJob(t, s, "queued") + } + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return len(f.starts) == 2 }) + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.starts) != 2 { + t.Fatal("global concurrency exceeded") + } + gpus := map[string]bool{} + for _, v := range f.jobs { + g := s.Config.WorkerGPUs[v.WorkerTarget] + if gpus[g] { + t.Fatal("two jobs on one GPU") + } + gpus[g] = true + } +} +func TestSchedulerResourceExhaustedRequeues(t *testing.T) { + s, e, f, ctx := setupEngine(t) + f.startErr = status.Error(codes.ResourceExhausted, "busy") + j := createJob(t, s, "queued") + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { + current, _ := s.store.Load().Get(ctx, j.ID, j.UserID) + return current.Status == "paused" && current.WorkerTarget == "" + }) +} +func TestSchedulerReconcilesWorkerRestart(t *testing.T) { + s, e, f, ctx := setupEngine(t) + j := createJob(t, s, "processing") + j.WorkerTarget = "a:1" + j.CheckpointToken = "old" + saveJob(t, s, &j) + f.jobs[j.ID.String()] = &pb.VodJobStatus{JobId: j.ID.String(), State: pb.VodJobState_VOD_JOB_STATE_PROCESSING, WorkerTarget: "a:1", Progress: &pb.VodProgress{FramesDone: 300, FramesTotal: 1800}} + if err := e.Reconcile(ctx); err != nil { + t.Fatal(err) + } + current, _ := s.store.Load().Get(ctx, j.ID, j.UserID) + if current.Status != "paused" || current.CheckpointToken != "opaque-checkpoint-300" { + t.Fatal(current) + } +} +func TestWorkerStatusTransitionsAndMonotonicProgress(t *testing.T) { + s, e, _, ctx := setupEngine(t) + j := createJob(t, s, "processing") + v := &pb.VodJobStatus{JobId: j.ID.String(), State: pb.VodJobState_VOD_JOB_STATE_PROCESSING, Progress: &pb.VodProgress{FramesDone: 900, FramesTotal: 1800, SegmentsDone: 3, Fraction: .5, ProcessingFps: 30}} + if err := e.apply(ctx, &j, v); err != nil { + t.Fatal(err) + } + v.State = pb.VodJobState_VOD_JOB_STATE_PAUSED + v.Progress.Fraction = .3 + v.CheckpointToken = "last-durable" + if err := e.apply(ctx, &j, v); err != nil { + t.Fatal(err) + } + if j.Progress != 50 || j.Status != "paused" { + t.Fatal(j) + } + writeFile(t, s.Path(j, "result")) + v.State = pb.VodJobState_VOD_JOB_STATE_DONE + if err := e.apply(ctx, &j, v); err != nil { + t.Fatal(err) + } + if j.Progress != 100 || j.CompletedAt == nil || j.ResultExpiresAt == nil { + t.Fatal(j) + } + v.State = pb.VodJobState_VOD_JOB_STATE_PROCESSING + if err := e.apply(ctx, &j, v); err != nil || j.Status != "done" { + t.Fatal("terminal state resurrected") + } +} +func TestQueuePositionAndETA(t *testing.T) { + s := testService(t) + a := createJob(t, s, "processing") + a.ProcessingFPS = 30 + a.FramesDone = 900 + saveJob(t, s, &a) + b := createJob(t, s, "queued") + c := createJob(t, s, "paused") + jobs, _ := s.store.Load().All(context.Background()) + v := s.View(c, jobs) + if v.QueuePosition == nil || *v.QueuePosition != 2 || v.ETA == nil || *v.ETA != 150 { + t.Fatalf("queue=%v ETA=%v", v.QueuePosition, v.ETA) + } + v = s.View(b, []Job{b, c}) + if v.ETA != nil { + t.Fatal("invented ETA without throughput") + } +} +func TestMigrationIsAdditiveAndRollback(t *testing.T) { + s := testService(t) + db := s.store.Load().DB + if err := db.Exec("CREATE TABLE existing_sentinel (id integer PRIMARY KEY); INSERT INTO existing_sentinel VALUES (42)").Error; err != nil { + t.Fatal(err) + } + up, err := os.ReadFile("../database/migration/sql/000018_create_vod_jobs.up.sql") + if err != nil { + t.Fatal(err) + } + if string(up) != schemaSQL { + t.Fatal("auto/versioned drift") + } + if err = db.Exec(string(up)).Error; err != nil { + t.Fatal(err) + } + down, err := os.ReadFile("../database/migration/sql/000018_create_vod_jobs.down.sql") + if err != nil { + t.Fatal(err) + } + if err = db.Exec(string(down)).Error; err != nil { + t.Fatal(err) + } + var id int + if err = db.Raw("SELECT id FROM existing_sentinel").Scan(&id).Error; err != nil || id != 42 { + t.Fatal("existing table changed", err) + } + if db.Migrator().HasTable(&Job{}) { + t.Fatal("rollback failed") + } + if err = AutoMigrate(context.Background(), db); err != nil { + t.Fatal(err) + } + j := createJob(t, s, "queued") + stale := j + j.Status = "paused" + saveJob(t, s, &j) + if err = s.store.Load().Save(context.Background(), &stale); !errors.Is(err, ErrConflict) { + t.Fatal(err) + } +} + +func (f *fakeWorker) LiveStreams(_ context.Context, target string) (uint64, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.remoteLive[target], nil +} + +func TestSchedulerObservesLiveFromOtherServerSlot(t *testing.T) { + s, e, f, ctx := setupEngine(t) + j := createJob(t, s, "queued") + f.remoteLive = map[string]uint64{"a:1": 1, "c:1": 1} + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + f.mu.Lock() + starts := len(f.starts) + f.remoteLive = map[string]uint64{} + f.mu.Unlock() + if starts != 0 { + t.Fatal("ignored other slot live streams") + } + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return len(f.starts) == 1 }) + f.mu.Lock() + f.remoteLive["a:1"] = 1 + f.mu.Unlock() + eventually(t, func() bool { current, _ := s.store.Load().Get(ctx, j.ID, j.UserID); return current.Status == "paused" }) +} + +func TestSchedulerPostgresLeaderExclusive(t *testing.T) { + s, e, f, _ := setupEngine(t) + createJob(t, s, "queued") + pool2, err := ai.NewPool([]string{"a:1", "b:1", "c:1"}, time.Second) + if err != nil { + t.Fatal(err) + } + defer pool2.Close() + other := NewEngine(s, pool2, f, fakeMemory{}) + ctx, cancel := context.WithCancel(context.Background()) + done1, done2 := make(chan struct{}), make(chan struct{}) + go func() { defer close(done1); e.Run(ctx) }() + go func() { defer close(done2); other.Run(ctx) }() + defer func() { cancel(); <-done1; <-done2 }() + eventually(t, func() bool { f.mu.Lock(); defer f.mu.Unlock(); return len(f.starts) == 1 }) + time.Sleep(1200 * time.Millisecond) + f.mu.Lock() + starts := len(f.starts) + f.mu.Unlock() + if starts != 1 { + t.Fatalf("multiple leaders admitted %d jobs", starts) + } +} diff --git a/internal/vod/vod_test.go b/internal/vod/vod_test.go new file mode 100644 index 0000000..3631cbe --- /dev/null +++ b/internal/vod/vod_test.go @@ -0,0 +1,518 @@ +package vod + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "io" + "log/slog" + "mime/multipart" + "net/http" + "net/http/httptest" + "net/textproto" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + "inno-live-server/internal/auth" + "inno-live-server/internal/config" +) + +func testService(t *testing.T) *Service { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL required for VOD PostgreSQL integration") + } + admin, e := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if e != nil { + t.Fatal(e) + } + schema := "vod_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if e = admin.Exec("CREATE SCHEMA " + schema).Error; e != nil { + t.Fatal(e) + } + u, e := url.Parse(dsn) + if e != nil { + t.Fatal(e) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + db, e := gorm.Open(postgres.Open(u.String()), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if e != nil { + t.Fatal(e) + } + t.Cleanup(func() { + sqlDB, _ := db.DB() + _ = sqlDB.Close() + _ = admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error + sqlAdmin, _ := admin.DB() + _ = sqlAdmin.Close() + }) + if e = AutoMigrate(context.Background(), db); e != nil { + t.Fatal(e) + } + cfg := config.DefaultVOD() + cfg.Enabled = true + cfg.Root = t.TempDir() + return New(cfg, NewStore(db), slog.New(slog.NewTextHandler(io.Discard, nil))) +} +func createJob(t *testing.T, s *Service, state string) Job { + t.Helper() + now := s.Now() + j := Job{ID: uuid.New(), UserID: uuid.New(), Status: state, CreatedAt: now, UpdatedAt: now, QueuedAt: now, DurationSec: 60, FramesTotal: 1800, Version: 1} + if e := s.store.Load().Create(context.Background(), &j); e != nil { + t.Fatal(e) + } + return j +} +func saveJob(t *testing.T, s *Service, j *Job) { + t.Helper() + if e := s.store.Load().Save(context.Background(), j); e != nil { + t.Fatal(e) + } +} +func writeFile(t *testing.T, path string) { + t.Helper() + if e := os.MkdirAll(filepath.Dir(path), 0750); e != nil { + t.Fatal(e) + } + if e := os.WriteFile(path, []byte("video"), 0640); e != nil { + t.Fatal(e) + } +} + +type activeUsers struct{} + +func (activeUsers) UserStatus(context.Context, uuid.UUID) (auth.UserStatus, error) { + return auth.UserStatusActive, nil +} + +var testKey = []byte("vod-test-only-signing-key-32-bytes") + +func requireUser() func(http.Handler) http.Handler { + return auth.RequireUser(auth.NewTokenService(nil, auth.TokenConfig{AccessKey: testKey, Issuer: "vod-test", Audience: "vod-api"}), activeUsers{}) +} +func token(t *testing.T, user uuid.UUID) string { + t.Helper() + now := time.Now() + claims := auth.AccessClaims{SessionID: uuid.NewString(), TokenType: "access", RegisteredClaims: jwt.RegisteredClaims{Issuer: "vod-test", Audience: jwt.ClaimStrings{"vod-api"}, Subject: user.String(), ID: uuid.NewString(), ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), IssuedAt: jwt.NewNumericDate(now), NotBefore: jwt.NewNumericDate(now.Add(-time.Second))}} + v, e := jwt.NewWithClaims(jwt.SigningMethodHS256, claims).SignedString(testKey) + if e != nil { + t.Fatal(e) + } + return v +} +func handler(s *Service) http.Handler { + mux := http.NewServeMux() + s.Register(mux, requireUser()) + return mux +} +func request(t *testing.T, h http.Handler, method, path string, user uuid.UUID, body io.Reader, contentType string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest(method, path, body) + if user != uuid.Nil { + r.Header.Set("Authorization", "Bearer "+token(t, user)) + } + if contentType != "" { + r.Header.Set("Content-Type", contentType) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +type fakeProbe struct { + duration float64 + err error +} + +func (p fakeProbe) Probe(context.Context, string) (Media, error) { + return Media{p.duration, 1800}, p.err +} +func uploadBody(t *testing.T, n, parts int) (*bytes.Buffer, string) { + t.Helper() + b := new(bytes.Buffer) + m := multipart.NewWriter(b) + for i := 0; i < parts; i++ { + headers := textproto.MIMEHeader{} + headers.Set("Content-Disposition", `form-data; name="file"; filename="../../private.mp4"`) + headers.Set("Content-Type", "video/mp4") + p, e := m.CreatePart(headers) + if e != nil { + t.Fatal(e) + } + _, _ = p.Write(bytes.Repeat([]byte("x"), n)) + } + _ = m.Close() + return b, m.FormDataContentType() +} + +func TestAPIOwnershipAndAuthentication(t *testing.T) { + s := testService(t) + j := createJob(t, s, "queued") + h := handler(s) + for _, method := range []string{"GET", "DELETE"} { + for _, path := range []string{"/vod/jobs/" + j.ID.String(), "/vod/jobs/" + j.ID.String() + "/result"} { + if method == "DELETE" && strings.HasSuffix(path, "result") { + continue + } + w := request(t, h, method, path, uuid.New(), nil, "") + if w.Code != 404 { + t.Fatalf("foreign %s %s = %d", method, path, w.Code) + } + } + } + for _, path := range []string{"/vod/jobs", "/vod/jobs/bad-id", "/vod/jobs/" + j.ID.String() + "/result"} { + if w := request(t, h, "GET", path, uuid.Nil, nil, ""); w.Code != 401 { + t.Fatalf("unauthenticated = %d", w.Code) + } else if w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("cache header missing") + } + } + if w := request(t, h, "GET", "/vod/jobs/bad-id", j.UserID, nil, ""); w.Code != 400 { + t.Fatal(w.Code) + } + if w := request(t, h, "GET", "/vod/jobs", uuid.New(), nil, ""); !strings.Contains(w.Body.String(), `"jobs":[]`) { + t.Fatal(w.Body.String()) + } + if w := request(t, h, "GET", "/vod/jobs/"+j.ID.String(), j.UserID, nil, ""); w.Code != 200 { + t.Fatal(w.Code) + } +} +func TestUploadLimitsAndValidation(t *testing.T) { + cases := []struct { + name string + n, parts int + duration float64 + err error + code int + }{{"accepted", 10, 1, 60, nil, 201}, {"byte_boundary", 32, 1, 3600, nil, 201}, {"bytes", 33, 1, 60, nil, 413}, {"duration", 10, 1, 3600.1, nil, 422}, {"empty", 0, 1, 60, nil, 400}, {"duplicates", 10, 2, 60, nil, 400}, {"unsupported", 10, 1, 60, ErrUnsupported, 415}, {"corrupt", 10, 1, 0, ErrInvalidMedia, 422}} + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + s := testService(t) + s.Config.MaxUploadBytes = 32 + s.Probe = fakeProbe{tc.duration, tc.err} + b, ct := uploadBody(t, tc.n, tc.parts) + r := httptest.NewRequest("POST", "/vod/jobs", b) + r.ContentLength = -1 + r.Header.Set("Content-Type", ct) + r.Header.Set("Authorization", "Bearer "+token(t, uuid.New())) + w := httptest.NewRecorder() + handler(s).ServeHTTP(w, r) + if w.Code != tc.code { + t.Fatalf("%d: %s", w.Code, w.Body.String()) + } + jobs, e := s.store.Load().All(context.Background()) + if e != nil { + t.Fatal(e) + } + files, _ := os.ReadDir(s.Config.Root) + if tc.code == 201 { + if len(jobs) != 1 || len(files) != 1 { + t.Fatal("missing persisted upload") + } + } else if len(jobs) != 0 || len(files) != 0 { + t.Fatal("failed upload retained data") + } + }) + } +} +func TestAPIDeletePreventsLateWorkerResurrection(t *testing.T) { + s := testService(t) + j := createJob(t, s, "processing") + stale := j + h := handler(s) + if w := request(t, h, "DELETE", "/vod/jobs/"+j.ID.String(), j.UserID, nil, ""); w.Code != 204 { + t.Fatal(w.Body.String()) + } + stale.Status = "done" + if e := s.store.Load().Save(context.Background(), &stale); !errors.Is(e, ErrConflict) { + t.Fatal("late update was accepted", e) + } + for _, method := range []string{"GET", "DELETE"} { + if w := request(t, h, method, "/vod/jobs/"+j.ID.String(), j.UserID, nil, ""); w.Code != 404 { + t.Fatal(w.Code) + } + } + all, _ := s.store.Load().All(context.Background()) + if all[0].Status != "cancelled" || all[0].OriginalDeleteAt == nil { + t.Fatal("lost cancellation obligation") + } +} +func TestResultAndExpiryBoundary(t *testing.T) { + s := testService(t) + now := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return now } + j := createJob(t, s, "processing") + s.Finish(&j, "done") + saveJob(t, s, &j) + writeFile(t, s.Path(j, "result")) + h := handler(s) + path := "/vod/jobs/" + j.ID.String() + "/result" + now = j.ResultExpiresAt.Add(-time.Nanosecond) + w := request(t, h, "GET", path, j.UserID, nil, "") + if w.Code != 200 || w.Body.String() != "video" || w.Header().Get("Accept-Ranges") != "none" { + t.Fatal(w.Code, w.Body.String()) + } + now = *j.ResultExpiresAt + w = request(t, h, "GET", path, j.UserID, nil, "") + if w.Code != 410 { + t.Fatal(w.Code) + } +} +func TestCursorOwnerTamperingAndPagination(t *testing.T) { + s := testService(t) + j := createJob(t, s, "queued") + other := createJob(t, s, "failed") + other.UserID = j.UserID + saveJob(t, s, &other) + h := handler(s) + w := request(t, h, "GET", "/vod/jobs?limit=1", j.UserID, nil, "") + var page struct { + Jobs []View + Next *string `json:"next_cursor"` + } + if json.Unmarshal(w.Body.Bytes(), &page) != nil || page.Next == nil || len(page.Jobs) != 1 { + t.Fatal(w.Body.String()) + } + for _, tc := range []struct { + user uuid.UUID + cursor string + }{{uuid.New(), *page.Next}, {j.UserID, *page.Next + "tampered"}} { + w = request(t, h, "GET", "/vod/jobs?cursor="+url.QueryEscape(tc.cursor), tc.user, nil, "") + if w.Code != 400 { + t.Fatal(w.Code) + } + } + w = request(t, h, "GET", "/vod/jobs?cursor="+url.QueryEscape(*page.Next), j.UserID, nil, "") + if json.Unmarshal(w.Body.Bytes(), &page) != nil || len(page.Jobs) != 1 || page.Next != nil { + t.Fatal(w.Body.String()) + } +} +func TestRetention24HoursAndSevenDays(t *testing.T) { + for _, state := range []string{"done", "failed", "cancelled"} { + t.Run(state, func(t *testing.T) { + s := testService(t) + now := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return now } + j := createJob(t, s, "processing") + s.Finish(&j, state) + saveJob(t, s, &j) + for _, kind := range []string{"original", "result", "partial"} { + writeFile(t, s.Path(j, kind)) + } + writeFile(t, filepath.Join(s.Path(j, "work"), "checkpoint.json")) + if j.OriginalDeleteAt.Sub(*j.CompletedAt) != 24*time.Hour { + t.Fatal("original deadline") + } + now = now.Add(24*time.Hour - time.Nanosecond) + if e := s.Sweep(context.Background()); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(s.Path(j, "original")); !os.IsNotExist(e) { + t.Fatal("original retained") + } + if state == "done" { + now = j.ResultExpiresAt.Add(-time.Nanosecond) + _ = s.Sweep(context.Background()) + if _, e := os.Stat(s.Path(j, "result")); e != nil { + t.Fatal("early result deletion") + } + now = *j.ResultExpiresAt + if e := s.Sweep(context.Background()); e != nil { + t.Fatal(e) + } + got, _ := s.store.Load().Get(context.Background(), j.ID, j.UserID) + if got.Status != "expired" { + t.Fatal(got.Status) + } + } + if _, e := os.Stat(s.Path(j, "result")); !os.IsNotExist(e) { + t.Fatal("result retained") + } + _ = s.Sweep(context.Background()) + var count int64 + s.store.Load().DB.Model(&DeletionLog{}).Where("job_id = ?", j.ID).Count(&count) + if count != 4 { + t.Fatalf("audit count %d", count) + } + }) + } +} +func TestSweeperDoesNotDeleteUnacknowledgedWorkerFiles(t *testing.T) { + s := testService(t) + j := createJob(t, s, "cancelled") + j.WorkerTarget = "unreachable" + saveJob(t, s, &j) + writeFile(t, s.Path(j, "original")) + if e := s.Sweep(context.Background()); e != nil { + t.Fatal(e) + } + if _, e := os.Stat(s.Path(j, "original")); e != nil { + t.Fatal("worker input deleted before cancel ack") + } +} +func TestDisabledCreatesNoRoutesOrWorkers(t *testing.T) { + c := config.DefaultVOD() + s := New(c, nil, nil) + h := handler(s) + if w := request(t, h, "GET", "/vod/jobs", uuid.Nil, nil, ""); w.Code != 404 { + t.Fatal(w.Code) + } + NewEngine(s, nil, nil, nil).Run(context.Background()) + s.RunSweeper(context.Background()) +} + +func TestStorageSymlinksCannotEscapeRoot(t *testing.T) { + s := testService(t) + j := createJob(t, s, "processing") + s.Finish(&j, "done") + saveJob(t, s, &j) + outside := t.TempDir() + writeFile(t, filepath.Join(outside, "input.mp4")) + writeFile(t, filepath.Join(outside, "output.mp4")) + if err := os.Symlink(outside, filepath.Join(s.Config.Root, j.ID.String())); err != nil { + t.Fatal(err) + } + if w := request(t, handler(s), "GET", "/vod/jobs/"+j.ID.String()+"/result", j.UserID, nil, ""); w.Code != 503 { + t.Fatal("escaped download", w.Code) + } + if err := s.Sweep(context.Background()); err == nil { + t.Fatal("escaped purge accepted") + } + if _, err := os.Stat(filepath.Join(outside, "input.mp4")); err != nil { + t.Fatal("outside data removed") + } +} +func TestSweeperCleansAbandonedUpload(t *testing.T) { + s := testService(t) + j := Job{ID: uuid.New()} + writeFile(t, s.Path(j, "original")) + dir := filepath.Dir(s.Path(j, "original")) + old := s.Now().Add(-s.Config.UploadTimeout - 2*s.Config.SweepInterval) + if err := os.Chtimes(dir, old, old); err != nil { + t.Fatal(err) + } + if err := s.Sweep(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatal("abandoned upload retained") + } +} + +func TestFFProbeRealMediaValidation(t *testing.T) { + if _, err := exec.LookPath("ffmpeg"); err != nil { + t.Skip("ffmpeg not installed") + } + if _, err := exec.LookPath("ffprobe"); err != nil { + t.Skip("ffprobe not installed") + } + for _, codec := range []string{"libx264", "mpeg4"} { + t.Run(codec, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "sample.mp4") + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + cmd := exec.CommandContext(ctx, "ffmpeg", "-v", "error", "-f", "lavfi", "-i", "color=c=black:s=64x64:r=5", "-t", "1", "-c:v", codec, "-threads", "1", "-pix_fmt", "yuv420p", path) + if b, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("fixture: %v %s", err, b) + } + m, err := (FFProbe{}).Probe(ctx, path) + if codec == "libx264" { + if err != nil || m.Duration != 1 || m.Frames != 5 { + t.Fatal(m, err) + } + } else if !errors.Is(err, ErrUnsupported) { + t.Fatal("unsupported codec accepted", err) + } + }) + } +} + +func TestCancellationDeadlinePurgesEvenWhenWorkerUnavailable(t *testing.T) { + s := testService(t) + now := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return now } + j := createJob(t, s, "processing") + j.WorkerTarget = "unreachable" + s.Finish(&j, "cancelled") + saveJob(t, s, &j) + writeFile(t, s.Path(j, "original")) + now = j.OriginalDeleteAt.Add(-time.Nanosecond) + if err := s.Sweep(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(s.Path(j, "original")); err != nil { + t.Fatal("removed before cancellation acknowledgement or deadline") + } + now = *j.OriginalDeleteAt + if err := s.Sweep(context.Background()); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(s.Path(j, "original")); !os.IsNotExist(err) { + t.Fatal("retention extended by unavailable worker") + } +} + +func TestActiveDownloadStopsOnDeleteOrExpiry(t *testing.T) { + for _, cause := range []string{"delete", "expiry"} { + t.Run(cause, func(t *testing.T) { + s := testService(t) + var clock atomic.Int64 + clock.Store(time.Now().UnixNano()) + s.Now = func() time.Time { return time.Unix(0, clock.Load()).UTC() } + j := createJob(t, s, "processing") + s.Finish(&j, "done") + saveJob(t, s, &j) + writeFile(t, s.Path(j, "result")) + const size = 64 << 20 + if err := os.Truncate(s.Path(j, "result"), size); err != nil { + t.Fatal(err) + } + server := httptest.NewServer(handler(s)) + defer server.Close() + client := &http.Client{Timeout: 5 * time.Second} + req, _ := http.NewRequest("GET", server.URL+"/vod/jobs/"+j.ID.String()+"/result", nil) + req.Header.Set("Authorization", "Bearer "+token(t, j.UserID)) + response, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + if response.StatusCode != 200 { + t.Fatal(response.StatusCode) + } + if cause == "delete" { + req, _ := http.NewRequest("DELETE", server.URL+"/vod/jobs/"+j.ID.String(), nil) + req.Header.Set("Authorization", "Bearer "+token(t, j.UserID)) + deleted, err := client.Do(req) + if err != nil { + t.Fatal(err) + } + _ = deleted.Body.Close() + if deleted.StatusCode != 204 { + t.Fatal(deleted.StatusCode) + } + } else { + clock.Store(j.ResultExpiresAt.UnixNano()) + } + n, err := io.Copy(io.Discard, response.Body) + if err == nil || n >= size { + t.Fatalf("download continued after %s: bytes=%d err=%v", cause, n, err) + } + }) + } +} diff --git a/internal/vod/worker_test.go b/internal/vod/worker_test.go new file mode 100644 index 0000000..61be940 --- /dev/null +++ b/internal/vod/worker_test.go @@ -0,0 +1,117 @@ +package vod + +import ( + "context" + "net" + "sync" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/protobuf/proto" + pb "inno-live-server/api/gen/aiv1" + "inno-live-server/internal/ai" +) + +type rpcWorker struct { + pb.UnimplementedVodProcessorServer + pb.UnimplementedAiProcessorServer + mu sync.Mutex + start *pb.StartVodJobRequest + purged bool +} + +func (f *rpcWorker) StartVodJob(_ context.Context, r *pb.StartVodJobRequest) (*pb.VodJobStatus, error) { + f.mu.Lock() + f.start = proto.Clone(r).(*pb.StartVodJobRequest) + f.mu.Unlock() + return &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PROCESSING, CheckpointToken: r.CheckpointToken}, nil +} +func (f *rpcWorker) WatchVodJob(r *pb.VodJobRef, stream grpc.ServerStreamingServer[pb.VodJobStatus]) error { + return stream.Send(&pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PAUSED, CheckpointToken: "checkpoint", Progress: &pb.VodProgress{FramesDone: 300, SegmentsDone: 1}}) +} +func (f *rpcWorker) GetVodJob(_ context.Context, r *pb.VodJobRef) (*pb.VodJobStatus, error) { + return &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PROCESSING}, nil +} +func (f *rpcWorker) PauseVodJob(_ context.Context, r *pb.PauseVodJobRequest) (*pb.VodJobStatus, error) { + return &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PAUSED, CheckpointToken: "checkpoint"}, nil +} +func (f *rpcWorker) CancelVodJob(_ context.Context, r *pb.CancelVodJobRequest) (*pb.VodJobStatus, error) { + f.mu.Lock() + f.purged = r.PurgeWorkDir + f.mu.Unlock() + return &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_CANCELLED}, nil +} +func (f *rpcWorker) ListVodJobs(context.Context, *pb.ListVodJobsRequest) (*pb.ListVodJobsResponse, error) { + return &pb.ListVodJobsResponse{Jobs: []*pb.VodJobStatus{{JobId: "job", State: pb.VodJobState_VOD_JOB_STATE_PAUSED}}}, nil +} +func (f *rpcWorker) ExportWhitelist(_ context.Context, r *pb.ExportWhitelistRequest) (*pb.ExportWhitelistResponse, error) { + return &pb.ExportWhitelistResponse{SessionId: r.SessionId, EmbeddingModel: "adaface-test", Entries: []*pb.WhitelistEmbedding{{EntryId: "face", Values: []float32{1, 0}}}}, nil +} +func TestGRPCAdapterContractAllMethods(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer() + fake := &rpcWorker{} + pb.RegisterVodProcessorServer(server, fake) + pb.RegisterAiProcessorServer(server, fake) + go func() { _ = server.Serve(listener) }() + defer server.Stop() + target := listener.Addr().String() + pool, err := ai.NewPool([]string{target}, time.Second) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + worker := GRPCWorker{pool} + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if n, err := worker.LiveStreams(ctx, target); err != nil || n != 2 { + t.Fatal("cross-slot live count", n, err) + } + whitelist, err := worker.Whitelist(ctx, "user:id") + if err != nil { + t.Fatal(err) + } + req := &pb.StartVodJobRequest{JobId: "job", InputPath: "/root/job/input.mp4", OutputPath: "/root/job/output.mp4", WorkDir: "/root/job/work", Resume: true, CheckpointToken: "checkpoint", WhitelistSource: &pb.StartVodJobRequest_Whitelist{Whitelist: whitelist}} + if _, err = worker.Start(ctx, target, req); err != nil { + t.Fatal(err) + } + fake.mu.Lock() + same := proto.Equal(fake.start, req) + fake.mu.Unlock() + if !same { + t.Fatal("Start contract changed") + } + seen := false + if err = worker.Watch(ctx, target, "job", func(v *pb.VodJobStatus) error { + seen = v.CheckpointToken == "checkpoint" && v.Progress.FramesDone == 300 + return nil + }); err != nil || !seen { + t.Fatal("Watch callback", err) + } + if _, err = worker.Get(ctx, target, "job"); err != nil { + t.Fatal(err) + } + if v, err := worker.Pause(ctx, target, "job"); err != nil || v.State != pb.VodJobState_VOD_JOB_STATE_PAUSED { + t.Fatal(err) + } + if _, err = worker.Cancel(ctx, target, "job"); err != nil { + t.Fatal(err) + } + fake.mu.Lock() + purged := fake.purged + fake.mu.Unlock() + if !purged { + t.Fatal("cancel did not purge work") + } + if v, err := worker.List(ctx, target); err != nil || len(v) != 1 { + t.Fatal(err) + } +} + +func (f *rpcWorker) ListSessions(context.Context, *pb.ListSessionsRequest) (*pb.ListSessionsResponse, error) { + return &pb.ListSessionsResponse{Sessions: []*pb.SessionInfo{{SessionId: "live-other-slot", ActiveStreamCount: 2}}}, nil +} From e0c7f013f9e7f3177fd5a5892517398f3b7ef60c Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:19:42 +0900 Subject: [PATCH 12/23] =?UTF-8?q?docs:=20VOD=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EA=B2=B0=EA=B3=BC=EC=99=80=20=EB=A1=A4?= =?UTF-8?q?=EB=B0=B1=20=EC=A0=88=EC=B0=A8=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/README.md | 131 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 api/vod/README.md diff --git a/api/vod/README.md b/api/vod/README.md new file mode 100644 index 0000000..7b01840 --- /dev/null +++ b/api/vod/README.md @@ -0,0 +1,131 @@ +# VOD 작업 서버 구현과 검증 + +## 계약과 적용 범위 + +- 이슈 #521, 1단계 계약 커밋 `6b8f58e`의 `api/vod/openapi.yaml` 기준 구현 +- 경로 `/vod/jobs`, `/vod/jobs/{job_id}`, `/vod/jobs/{job_id}/result`, snake_case JSON 사용; `/api/v1` 별칭 미등록 +- 확정 인터페이스 `/workspace/innolive-vod/interface-A.md`(innolive-ai `f0c24dd`, innolive-server `d3d1fa3` 기준)의 `VodProcessor` 서비스와 필드 번호 반영 +- 기존 `AiProcessor` 연결·화이트리스트 export·인증·사용자 작업 게이트 재사용 +- 워커 취소 상태 `cancelled` 추가; DELETE는 취소 의무를 DB에 기록하고 목록·상세·다운로드에서 즉시 숨김 +- 계획서 `/workspace/innolive-video/plan.md` §3.2-0, §3.2-1, §3.5의 기존 워커 재사용·로컬 공유 디스크·라이브 우선 원칙 반영 +- 작업 큐는 PostgreSQL 테이블과 advisory lock 사용; 게스트 입장 대기열 및 Redis 데이터와 분리 + +## 설정 + +| 환경 변수 | 기본값 | 의미 | +| --- | --- | --- | +| `VOD_ENABLED` | `false` | 라우트·DB 연결·스케줄러·스위퍼 생성 여부 | +| `VOD_ROOT` | `/var/lib/innolive/vod` | 서버와 워커의 동일한 절대 공유 경로 | +| `VOD_MAX_UPLOAD_BYTES` | `2147483648` | 파일당 2 GiB, multipart 전체는 추가 1 MiB 허용 | +| `VOD_MAX_DURATION_SEC` | `3600` | 영상당 60분 | +| `VOD_MAX_CONCURRENCY` | `1` | 전체 VOD 동시성 상한 | +| `VOD_WORKER_GPUS` | 빈 값 | 쉼표로 구분한 `host:port=GPU-UUID` 매핑 | +| `VOD_MIN_FREE_MIB` | `4096` | GPU 여유 메모리 하한 | +| `VOD_RESULT_RETENTION_SEC` | `604800` | 완료 후 결과 보관 7일 | +| `VOD_ORIGINAL_RETENTION_SEC` | `86400` | 종료 후 원본 파기 상한, 0..86400 | +| `VOD_SWEEP_INTERVAL` | `1m` | 정리 재시도·고아 업로드 탐색 주기 | +| `VOD_UPLOAD_TIMEOUT` | `30m` | 업로드 읽기 및 미디어 검사 기한 | + +- GPU당 VOD 1건은 고정 정책; 같은 GPU의 다른 워커에 라이브가 있어도 VOD 배정 금지 +- 모든 현재 풀 타깃의 GPU UUID 매핑 필요; 누락된 교체 슬롯이나 GPU 메모리 조회 실패 시 VOD 배정 중단 +- `nvidia-smi --query-gpu=uuid,memory.free --format=csv,noheader,nounits`로 실제 여유 메모리 조회 +- `ListSessions.active_stream_count`로 다른 서버 슬롯의 라이브 스트림도 확인; 로컬 라이브 배정은 풀 잠금 안에서 채널만 닫고 즉시 반환 +- 별도 양보 제어 고루틴에서 Pause RPC 호출; Start·DB·메모리 조회 지연이 Pause 전달을 막지 않도록 분리 +- 기본 300프레임 세그먼트의 완료 수로 API 진행률 산출; 재개 시 진행률 감소 방지, DONE 전 최대 99 +- 전체 대기열 순번과 최근 처리 FPS 기반 ETA 계산; 처리 속도나 라이브 대기 시간을 예측할 수 없으면 null 반환 +- 체크포인트 토큰·프레임·세그먼트 위치 DB 보존; 재개 시 `resume=true`와 같은 토큰, 다시 export한 whitelist 전달 +- 생체 임베딩의 DB·파일 저장 없음; API 커서 HMAC 키는 프로세스 메모리에만 생성하므로 재시작/서버 슬롯 전환 시 기존 커서가 400으로 무효화될 수 있음 + +## 보존과 장애 처리 + +- 완료·실패·취소가 확인되면 원본·중간 파일을 다음 정리 실행에서 조기 파기; 24시간은 최소 보관 기간이 아닌 상한 +- 취소 RPC 응답이 없으면 파기 시한까지 재시도; 시한에는 서버 소유 파일 경로를 제거하고 `vod_retention_worker_unconfirmed` 기록 +- 만료 시각에 맞춘 타이머와 주기적 재시도 병행; 다운로드는 스위퍼와 무관하게 만료 시 차단 +- 다운로드 중 매 청크에서 소유 작업의 유효 상태 재확인; 별도 감시로 느린 클라이언트의 막힌 쓰기도 중단 +- 삭제마다 작업 UUID·파일 종류·상대경로 SHA-256·삭제 시각·사유를 `vod_deletion_logs`와 구조화 로그에 기록; 파일 제거 후 DB 기록 전에 중단된 경우 재시도 시 보완 +- 삭제 로그의 동일 작업·종류·경로 중복 기록 방지; 기존 파일 원문이나 임베딩 로그 출력 없음 +- 작업 행 없이 남은 UUID 업로드 디렉터리는 업로드 제한 시간+스위퍼 주기 경과 후 재조회하여 정리 +- `os.OpenRoot`로 결과 다운로드·파기 경로를 공유 루트 안으로 제한 +- 낙관적 버전 검증으로 늦은 워커 응답의 삭제 작업 복원 차단 +- PostgreSQL advisory lock으로 서버 슬롯 간 스케줄러·스위퍼 리더 1개 유지; 시작 시 List/Get/Pause/Cancel로 기존 워커 상태 대조 +- 응답 없는 기존 워커가 실행 중일 가능성이 있으면 중복 실행을 피하도록 재조정 대기; API와 파기 스위퍼는 계속 동작 + +## 기본 비활성과 배포 + +- `.github/workflows/deploy.yml:3-13`: main push에서 `api/**`, `cmd/**`, `internal/**` 등의 변경이 자동 배포를 트리거 +- 이 변경은 해당 경로를 포함하므로 main에 머지하면 배포 대상에 해당; 이번 작업에서 push·PR 변경·머지·운영 접속 미수행 +- `internal/server/vod.go:22`의 라우트 조립과 `:30`의 백그라운드 시작 모두 `VOD_ENABLED` 확인 후 실행 +- 비활성 시 VOD 라우트 미등록, VOD DB 연결·스케줄러·스위퍼·양보 고루틴 생성 없음 +- 기존 `RunLimitEnforcer` 컨텍스트 및 `WaitGuestCleanup` 종료 훅에 VOD 생명주기 연결; PR #519의 main·배포·세션 이관 파일 수정 없음 +- 마이그레이션은 플래그와 별개로 추가 전용 테이블·인덱스만 생성; 기존 auto/versioned/off 방식 유지 + +## 활성화 전 필요한 검증과 한계 + +- 실제 innolive-ai VOD 워커·NVIDIA GPU E2E는 미실행; gRPC 가짜 서버와 가짜 워커로 계약·제어 흐름 검증 +- 워커의 동일 proto 배포, 공유 루트 마운트, 서버 UID 65532와 워커의 읽기/쓰기 권한 정합 확인 필요; 현재 작업 디렉터리 0750, 입력 파일 0640 사용 +- 서버 컨테이너의 `nvidia-smi`와 NVIDIA utility 접근, 모든 현재/교체 슬롯의 정확한 GPU UUID 매핑 확인 필요 +- 갑작스러운 워커 종료·풀에서 제거된 옛 주소의 응답 불능 시 자동 재조정이 대기할 수 있음; 옛 워커 정지 확인과 타깃/매핑 복구 필요 +- 체크포인트 직후 Watch 응답 유실 시 마지막으로 DB에 저장한 토큰과 파일의 토큰 불일치 가능; 워커의 CHECKPOINT_MISMATCH는 실패로 보존하며 임의 초기화하지 않음 +- 서버/DB/파일시스템 장기 장애나 워커가 이미 연 파일 디스크립터를 유지하는 상황에서 물리 파기 시각의 절대 보장 불가; 파기 실패 및 미확인 취소 로그에 대한 운영 감시 필요 +- 계정 탈퇴 시 모든 VOD 작업을 일괄 취소하는 연결, 장기 대기 작업 최대 수명, 사용자별 저장 용량 제한은 이번 범위에 없음 +- 목록/스케줄링은 현재 전체 작업 스냅샷 사용; 대규모 누적 작업의 SQL 페이지네이션·메타데이터 보존 최적화 필요 + +## 롤백 + +- 먼저 VOD를 비활성화하고 실행 중인 작업의 Pause/Cancel 및 서버 종료 완료 확인 +- 원본·결과·중간 파일 정리 및 파기 로그 보존 완료 후 아래 down 파일 적용 +- 파일: `internal/database/migration/sql/000018_create_vod_jobs.down.sql` + +```sql +DROP TABLE IF EXISTS vod_deletion_logs; +DROP TABLE IF EXISTS vod_jobs; +``` + +- 두 테이블의 인덱스는 테이블과 함께 제거; 기존 테이블·인덱스에 대한 DROP/ALTER 없음 +- versioned 모드에서는 기존 migrate 도구로 18→17 한 단계 down 적용하여 `schema_migrations` 버전도 동기화 +- auto 모드에서는 위 SQL 적용 후 VOD가 포함되지 않은 이전 바이너리로 복귀; 새 바이너리의 auto 마이그레이션은 재기동 시 두 테이블을 다시 생성 +- 파일 데이터는 SQL down으로 삭제되지 않으므로 반드시 파일 정리 선행 +- 동일 SQL의 auto/versioned 일치·중복 적용·down·재적용·기존 sentinel 테이블 보존을 실제 PostgreSQL 테스트로 검증 + +## 검증 명령과 결과 + +검증 환경: Go 1.25, PostgreSQL 17.11, 로컬 가짜 gRPC 워커, ffmpeg/ffprobe. 실제 운영 GPU 미사용. + +```bash +# Docker CLI는 있으나 데몬 접근 불가. sudo 없이 설치된 PostgreSQL 사용. +/usr/lib/postgresql/17/bin/initdb -D /tmp/innolive-vod-pg -A trust --no-locale +/usr/lib/postgresql/17/bin/pg_ctl -D /tmp/innolive-vod-pg \ + -l /tmp/innolive-vod-pg.log -o '-h 127.0.0.1 -p 55432 -k /tmp' start +/usr/lib/postgresql/17/bin/createdb -h 127.0.0.1 -p 55432 -U box \ + -E UTF8 -T template0 vod_test +export TEST_DATABASE_URL='postgres://box@127.0.0.1:55432/vod_test?sslmode=disable' +go build ./... +go vet ./... +go test -count=1 -json ./... +go test -race -count=1 -json ./... +``` + +- 최초 PostgreSQL 기동: 기본 소켓 디렉터리 쓰기 권한 오류로 실패; `-k /tmp` 지정 후 정상 기동 +- 최초 전체 테스트: SQL_ASCII 기본 DB의 `client_encoding=UTF8` 요구 오류로 기존 DB 테스트 34개 실패; UTF-8 전용 DB 생성 후 해결 +- 초기 VOD race 테스트: Watch 고루틴의 가변 Job 읽기 경합 발견; 불변 job ID 캡처로 수정 후 재검증 통과 +- 다운로드 중단 테스트: 200ms 감시만으로 빠른 전송이 DELETE 후 끝나는 결함 발견; 청크별 DB 재검증 추가 후 삭제·만료 중단 모두 통과 +- protobuf 생성: Makefile의 `proto` 명령과 동일한 protoc 옵션 직접 실행(make 미설치); protoc 33.0, protoc-gen-go 1.36.12, protoc-gen-go-grpc 1.6.2 사용 + +최종 명령별 집계는 아래 표에 기록한다. 테스트 수는 하위 테스트를 포함하는 Go JSON pass/skip 이벤트 수이며 테스트 없는 5개 패키지는 건너뜀 수에서 제외한다. + +| 명령 | 결과 | +| --- | --- | +| `go build ./...` | 통과 | +| `go vet ./...` | 통과 | +| `go test -count=1 -json ./...` | 1,301 통과 / 0 실패 / 1 건너뜀; 테스트 패키지 15개 통과 | +| `go test -race -count=1 -json ./...` | 1,301 통과 / 0 실패 / 1 건너뜀; race 0건; 테스트 패키지 15개 통과 | + +- 유일한 건너뜀: 기존 `TestLocalDeviceMediaProbe`, 명시적 장치 probe 실행 opt-in 미설정 +- 라이브 회귀: `TestVODLiveAcquireRemainsImmediate`, `TestVODGPUAdmissionAndLiveMovePreemption`, `TestSchedulerLivePreemptionCheckpointResume`, `TestSchedulerObservesLiveFromOtherServerSlot`, `TestVODUnmappedReplacementStopsAdmission` +- 플래그 검증: `TestVODDisabledNoRoutesOrBackgroundLifecycle`, `TestDisabledCreatesNoRoutesOrWorkers` +- 파기 경계: `TestRetention24HoursAndSevenDays`, `TestCancellationDeadlinePurgesEvenWhenWorkerUnavailable` +- 동시성·복구: `TestSchedulerGlobalAndGPUConcurrency`, `TestSchedulerPostgresLeaderExclusive`, `TestSchedulerReconcilesWorkerRestart` +- 실제 DB 및 API: 본인 작업·타인 404·로그인 필수·크기/길이 경계·미디어 검증·커서 변조·삭제 후 응답·활성 다운로드 중단·파기 감사·마이그레이션 롤백 검증 + +- 최종 `internal/ai` 79개 및 `internal/vod` 38개 테스트 이벤트 모두 일반·race 실행 통과 +- `git diff --check` 통과; PR #519 파일 목록과 변경 경로의 교집합 없음 From 5de7d86feccc052ca398e2616764f5d75e6d6f20 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:29:15 +0900 Subject: [PATCH 13/23] =?UTF-8?q?feat:=20VOD=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=EC=9B=90=EB=B3=B8=20=ED=8C=8C=EC=9D=BC=EB=AA=85=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EA=B3=BC=20=EB=B3=B4=EC=A1=B4=EA=B8=B0=EA=B0=84=20?= =?UTF-8?q?=ED=8C=8C=EA=B8=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/README.md | 56 ++- api/vod/openapi.yaml | 12 + internal/auth/account_withdrawal.go | 3 + internal/auth/vod_filename.go | 24 ++ .../000019_add_vod_original_filename.down.sql | 1 + .../000019_add_vod_original_filename.up.sql | 1 + internal/vod/filename.go | 37 ++ internal/vod/filename.sql | 1 + internal/vod/filename_test.go | 368 ++++++++++++++++++ internal/vod/http.go | 8 +- internal/vod/service.go | 7 + internal/vod/store.go | 11 +- internal/vod/sweeper.go | 12 + 13 files changed, 532 insertions(+), 9 deletions(-) create mode 100644 internal/auth/vod_filename.go create mode 100644 internal/database/migration/sql/000019_add_vod_original_filename.down.sql create mode 100644 internal/database/migration/sql/000019_add_vod_original_filename.up.sql create mode 100644 internal/vod/filename.go create mode 100644 internal/vod/filename.sql create mode 100644 internal/vod/filename_test.go diff --git a/api/vod/README.md b/api/vod/README.md index 7b01840..6782bb6 100644 --- a/api/vod/README.md +++ b/api/vod/README.md @@ -8,6 +8,9 @@ - 기존 `AiProcessor` 연결·화이트리스트 export·인증·사용자 작업 게이트 재사용 - 워커 취소 상태 `cancelled` 추가; DELETE는 취소 의무를 DB에 기록하고 목록·상세·다운로드에서 즉시 숨김 - 계획서 `/workspace/innolive-video/plan.md` §3.2-0, §3.2-1, §3.5의 기존 워커 재사용·로컬 공유 디스크·라이브 우선 원칙 반영 +- 업로드·목록·상세 응답에 선택 문자열 `original_filename` 추가; DB NULL은 JSON 필드 생략, 기존 작업도 생략 +- multipart `file`의 filename에서 `/`·`\` 기준 마지막 요소만 보존; Unicode 제어문자와 NUL 제거, 잘못된 UTF-8은 U+FFFD 치환, 앞뒤 공백 정리 후 UTF-8 경계에서 255바이트로 자르고 끝 공백 재정리 +- 정리 결과가 빈 값·`.`·`..`이면 미저장; 저장 경로는 UUID 기반으로 유지, 다운로드 `Content-Disposition`은 `vod-.mp4` 유지 - 작업 큐는 PostgreSQL 테이블과 advisory lock 사용; 게스트 입장 대기열 및 Redis 데이터와 분리 ## 설정 @@ -43,6 +46,9 @@ - 만료 시각에 맞춘 타이머와 주기적 재시도 병행; 다운로드는 스위퍼와 무관하게 만료 시 차단 - 다운로드 중 매 청크에서 소유 작업의 유효 상태 재확인; 별도 감시로 느린 클라이언트의 막힌 쓰기도 중단 - 삭제마다 작업 UUID·파일 종류·상대경로 SHA-256·삭제 시각·사유를 `vod_deletion_logs`와 구조화 로그에 기록; 파일 제거 후 DB 기록 전에 중단된 경우 재시도 시 보완 +- `original_filename`은 원본 파일 조기 파기 후에도 결과 보존기간(기본 7일, `VOD_RESULT_RETENTION_SEC`)까지 보존; 만료 시 결과 파기 스위퍼가 NULL로 제거하고 만료 응답은 즉시 필드 생략 +- 삭제·취소·실패 시 파일명 즉시 NULL 처리; 계정 탈퇴 트랜잭션도 VOD 활성화 여부와 무관하게 남는 행의 파일명을 NULL로 처리하고 버전 증가로 늦은 워커 저장 차단 +- 파일명 파기는 워커 응답·파일 제거 성공을 기다리지 않으며 DB 장애·버전 충돌 시 스위퍼에서 재시도; `vod_deletion_logs`와 애플리케이션·SQL 로그에 원본 파일명 미기록 - 삭제 로그의 동일 작업·종류·경로 중복 기록 방지; 기존 파일 원문이나 임베딩 로그 출력 없음 - 작업 행 없이 남은 UUID 업로드 디렉터리는 업로드 제한 시간+스위퍼 주기 경과 후 재조회하여 정리 - `os.OpenRoot`로 결과 다운로드·파기 경로를 공유 루트 안으로 제한 @@ -57,7 +63,7 @@ - `internal/server/vod.go:22`의 라우트 조립과 `:30`의 백그라운드 시작 모두 `VOD_ENABLED` 확인 후 실행 - 비활성 시 VOD 라우트 미등록, VOD DB 연결·스케줄러·스위퍼·양보 고루틴 생성 없음 - 기존 `RunLimitEnforcer` 컨텍스트 및 `WaitGuestCleanup` 종료 훅에 VOD 생명주기 연결; PR #519의 main·배포·세션 이관 파일 수정 없음 -- 마이그레이션은 플래그와 별개로 추가 전용 테이블·인덱스만 생성; 기존 auto/versioned/off 방식 유지 +- 마이그레이션은 플래그와 별개로 추가 전용 테이블·인덱스·nullable 컬럼 생성; 기존 auto/versioned/off 방식 유지 ## 활성화 전 필요한 검증과 한계 @@ -67,27 +73,30 @@ - 갑작스러운 워커 종료·풀에서 제거된 옛 주소의 응답 불능 시 자동 재조정이 대기할 수 있음; 옛 워커 정지 확인과 타깃/매핑 복구 필요 - 체크포인트 직후 Watch 응답 유실 시 마지막으로 DB에 저장한 토큰과 파일의 토큰 불일치 가능; 워커의 CHECKPOINT_MISMATCH는 실패로 보존하며 임의 초기화하지 않음 - 서버/DB/파일시스템 장기 장애나 워커가 이미 연 파일 디스크립터를 유지하는 상황에서 물리 파기 시각의 절대 보장 불가; 파기 실패 및 미확인 취소 로그에 대한 운영 감시 필요 -- 계정 탈퇴 시 모든 VOD 작업을 일괄 취소하는 연결, 장기 대기 작업 최대 수명, 사용자별 저장 용량 제한은 이번 범위에 없음 +- 계정 탈퇴 시 원본 파일명은 파기하지만 모든 VOD 작업을 일괄 취소하는 연결, 장기 대기 작업 최대 수명, 사용자별 저장 용량 제한은 이번 범위에 없음 - 목록/스케줄링은 현재 전체 작업 스냅샷 사용; 대규모 누적 작업의 SQL 페이지네이션·메타데이터 보존 최적화 필요 ## 롤백 - 먼저 VOD를 비활성화하고 실행 중인 작업의 Pause/Cancel 및 서버 종료 완료 확인 -- 원본·결과·중간 파일 정리 및 파기 로그 보존 완료 후 아래 down 파일 적용 -- 파일: `internal/database/migration/sql/000018_create_vod_jobs.down.sql` +- 전체 VOD 롤백 시 원본·결과·중간 파일 정리 및 파기 로그 보존 완료 후 아래 down 파일 적용 +- 원본 파일명 기능만 롤백: 서버 중지 후 `internal/database/migration/sql/000019_add_vod_original_filename.down.sql` 적용(`ALTER TABLE vod_jobs DROP COLUMN IF EXISTS original_filename;`), 이전 VOD 바이너리로 복귀 +- versioned 모드의 기능 롤백은 19→18 한 단계 down; 작업 행·결과·감사 로그는 보존하지만 원본 파일명은 복구 불가 +- auto 모드도 동일 down SQL 적용 후 이전 바이너리로 복귀; 새 바이너리 재기동 시 nullable 컬럼 재생성 +- 전체 VOD 롤백은 `000019_add_vod_original_filename.down.sql` → `internal/database/migration/sql/000018_create_vod_jobs.down.sql` 순서 적용 ```sql DROP TABLE IF EXISTS vod_deletion_logs; DROP TABLE IF EXISTS vod_jobs; ``` -- 두 테이블의 인덱스는 테이블과 함께 제거; 기존 테이블·인덱스에 대한 DROP/ALTER 없음 -- versioned 모드에서는 기존 migrate 도구로 18→17 한 단계 down 적용하여 `schema_migrations` 버전도 동기화 +- 두 VOD 테이블의 인덱스는 테이블과 함께 제거; VOD 외 기존 테이블·인덱스에 대한 DROP/ALTER 없음 +- versioned 모드의 전체 롤백은 기존 migrate 도구로 19→18→17 두 단계 down 적용하여 `schema_migrations` 버전도 동기화 - auto 모드에서는 위 SQL 적용 후 VOD가 포함되지 않은 이전 바이너리로 복귀; 새 바이너리의 auto 마이그레이션은 재기동 시 두 테이블을 다시 생성 - 파일 데이터는 SQL down으로 삭제되지 않으므로 반드시 파일 정리 선행 - 동일 SQL의 auto/versioned 일치·중복 적용·down·재적용·기존 sentinel 테이블 보존을 실제 PostgreSQL 테스트로 검증 -## 검증 명령과 결과 +## 기존 phase2 검증 명령과 결과 검증 환경: Go 1.25, PostgreSQL 17.11, 로컬 가짜 gRPC 워커, ffmpeg/ffprobe. 실제 운영 GPU 미사용. @@ -129,3 +138,36 @@ go test -race -count=1 -json ./... - 최종 `internal/ai` 79개 및 `internal/vod` 38개 테스트 이벤트 모두 일반·race 실행 통과 - `git diff --check` 통과; PR #519 파일 목록과 변경 경로의 교집합 없음 + + +## 원본 파일명 추가 검증 + +- 환경: Go 1.27.3, 로컬 PostgreSQL 17.11, UTF-8 `vod_test` DB, 가짜 gRPC 워커 및 ffmpeg/ffprobe; 운영 서버·GPU 미사용 +- 기존 phase2의 `/tmp/innolive-vod-pg` 클러스터를 사용하되 `55432` 포트 충돌로 `55433` 포트에서 기동 + +```bash +/usr/lib/postgresql/17/bin/pg_ctl -D /tmp/innolive-vod-pg \ + -l /tmp/innolive-vod-filename-pg.log -o '-h 127.0.0.1 -p 55433 -k /tmp' start +export TEST_DATABASE_URL='postgres://box@127.0.0.1:55433/vod_test?sslmode=disable' +go build ./... +go vet ./... +go test -count=1 ./internal/vod ./internal/auth +go test -race -count=1 -json ./... +``` + +| 검증 | 결과 | +| --- | --- | +| `go build ./...` | 통과 | +| `go vet ./...` | 통과 | +| VOD·인증 집중 테스트 | 2개 패키지 통과 | +| `go test -race -count=1 -json ./...` | 1,343 통과 / 0 실패 / 1 건너뜀; race 0건; 테스트 패키지 15개 통과 | +| OpenAPI | YAML 파싱·선택 문자열 스키마·TypeScript 선택 필드 확인 | + +- 테스트 수는 하위 테스트 포함 Go JSON 이벤트 기준; 테스트 없는 5개 패키지는 테스트 건너뜀 수에서 제외 +- 건너뜀: 기존 `TestLocalDeviceMediaProbe`, 명시적 장치 probe opt-in 미설정 +- 추가 테스트 이벤트 42개 통과; `internal/vod` 전체 80개 통과 +- 정리: 경로·역슬래시·혼합 경로·제어문자·NUL·공백·잘못된 UTF-8·255바이트 경계·한글/이모지 중간 절단·빈 값·점·끝 구분자 확인 +- API 및 개인정보: 업로드·목록·상세의 포함/생략, 다운로드 파일명 유지, 기본 7일/사용자 지정 보존기간 경계, 만료 전 원본 조기 파기 후 이름 보존, 만료 응답 즉시 생략, SQL NULL 및 결과 파일 제거 확인 +- 파기: 삭제·취소·실패 및 계정 탈퇴(VOD 비활성 포함), 탈퇴 실패 롤백, 다른 사용자 보존, 늦은 작업 저장 충돌, 파일 파기 실패 시에도 이름 제거, 구조화/SQL/파기 감사 로그 미노출 확인 +- 마이그레이션: 000019 up/down 반복 적용, 작업 행 보존, 기존 행 NULL, auto/versioned SQL 일치, auto 재적용 확인; 기존 마이그레이션 수정 없음 +- `git diff --check` 통과; PR #519 변경 파일과 교집합 없음; 원격 쓰기 미수행 diff --git a/api/vod/openapi.yaml b/api/vod/openapi.yaml index e9f527e..3968cbd 100644 --- a/api/vod/openapi.yaml +++ b/api/vod/openapi.yaml @@ -155,6 +155,7 @@ info: type VodFailureCode = 'processing_failed' | 'storage_failed'; interface VodJob { job_id: string; + original_filename?: string; status: VodJobStatus; progress: number; queue_position: number | null; @@ -486,6 +487,16 @@ components: job_id: type: string format: uuid + original_filename: + type: string + maxLength: 255 + description: >- + multipart file의 정리된 원본 파일명. /와 역슬래시 기준 마지막 요소만 남기고 + 제어문자(NUL 포함) 제거, 잘못된 UTF-8을 U+FFFD로 치환, 앞뒤 공백 제거 후 + UTF-8 경계에서 최대 255바이트로 자르고 끝 공백을 다시 제거한다. + 빈 값, . 또는 .., 기존 미저장 작업은 필드 생략. 결과 보존기간(기본 7일, + VOD_RESULT_RETENTION_SEC) 만료 및 삭제·취소·실패·계정 탈퇴 시 DB에서 NULL로 파기한다. + 만료 시 스위퍼 실행 전에도 응답에서 생략한다. 다운로드 파일명은 vod-.mp4로 유지한다. status: $ref: '#/components/schemas/VodJobStatus' progress: @@ -540,6 +551,7 @@ components: type: string example: job_id: 8f46f610-3207-4b86-8a22-18a1eeefb781 + original_filename: video.mp4 status: paused progress: 37 queue_position: 1 diff --git a/internal/auth/account_withdrawal.go b/internal/auth/account_withdrawal.go index 1df3878..940b1fa 100644 --- a/internal/auth/account_withdrawal.go +++ b/internal/auth/account_withdrawal.go @@ -100,6 +100,9 @@ func (s *gormWithdrawalAccountStore) MarkUserDeleted(ctx context.Context, userID if user.Status != UserStatusActive { return ErrUserInactive } + if err := clearVODOriginalFilenames(tx, userID); err != nil { + return err + } // 계정이 가진 행을 한 트랜잭션에서 모두 지운다. 아래 사용자 조회가 더는 행을 // 찾지 못하므로 인증은 이전 access token을 거절하고, 플랫폼 subject와 이메일 diff --git a/internal/auth/vod_filename.go b/internal/auth/vod_filename.go new file mode 100644 index 0000000..91f88b3 --- /dev/null +++ b/internal/auth/vod_filename.go @@ -0,0 +1,24 @@ +package auth + +import ( + "github.com/google/uuid" + "gorm.io/gorm" +) + +// VOD rows outlive accounts. Erase display metadata in the withdrawal +// transaction even when VOD is disabled, without depending on the vod package. +func clearVODOriginalFilenames(tx *gorm.DB, userID uuid.UUID) error { + var exists bool + if err := tx.Raw(`SELECT EXISTS ( + SELECT 1 FROM pg_attribute WHERE attrelid = to_regclass('vod_jobs') + AND attname = 'original_filename' AND NOT attisdropped + )`).Scan(&exists).Error; err != nil { + return err + } + // Support databases predating the optional VOD migrations. + if !exists { + return nil + } + return tx.Exec(`UPDATE vod_jobs SET original_filename = NULL, version = version + 1 + WHERE user_id = ? AND original_filename IS NOT NULL`, userID).Error +} diff --git a/internal/database/migration/sql/000019_add_vod_original_filename.down.sql b/internal/database/migration/sql/000019_add_vod_original_filename.down.sql new file mode 100644 index 0000000..c4dc5dd --- /dev/null +++ b/internal/database/migration/sql/000019_add_vod_original_filename.down.sql @@ -0,0 +1 @@ +ALTER TABLE vod_jobs DROP COLUMN IF EXISTS original_filename; diff --git a/internal/database/migration/sql/000019_add_vod_original_filename.up.sql b/internal/database/migration/sql/000019_add_vod_original_filename.up.sql new file mode 100644 index 0000000..f0f9e00 --- /dev/null +++ b/internal/database/migration/sql/000019_add_vod_original_filename.up.sql @@ -0,0 +1 @@ +ALTER TABLE vod_jobs ADD COLUMN IF NOT EXISTS original_filename text; diff --git a/internal/vod/filename.go b/internal/vod/filename.go new file mode 100644 index 0000000..e344b6f --- /dev/null +++ b/internal/vod/filename.go @@ -0,0 +1,37 @@ +package vod + +import ( + "strings" + "time" + "unicode" + "unicode/utf8" +) + +// Original filenames are display metadata only, never filesystem paths or logs. +func sanitizeOriginalFilename(name string) *string { + name = name[strings.LastIndexAny(name, `/\`)+1:] + name = strings.ToValidUTF8(name, "\uFFFD") + name = strings.Map(func(r rune) rune { + if unicode.IsControl(r) { + return -1 + } + return r + }, name) + name = strings.TrimSpace(name) + if len(name) > 255 { + end := 255 + for !utf8.RuneStart(name[end]) { + end-- + } + name = strings.TrimSpace(name[:end]) + } + if name == "" || name == "." || name == ".." { + return nil + } + return &name +} + +func (j Job) filenameExpired(now time.Time) bool { + return j.DeletedAt != nil || j.Status == "failed" || j.Status == "cancelled" || j.Status == "expired" || + (j.ResultExpiresAt != nil && !now.Before(*j.ResultExpiresAt)) +} diff --git a/internal/vod/filename.sql b/internal/vod/filename.sql new file mode 100644 index 0000000..f0f9e00 --- /dev/null +++ b/internal/vod/filename.sql @@ -0,0 +1 @@ +ALTER TABLE vod_jobs ADD COLUMN IF NOT EXISTS original_filename text; diff --git a/internal/vod/filename_test.go b/internal/vod/filename_test.go new file mode 100644 index 0000000..d0a92be --- /dev/null +++ b/internal/vod/filename_test.go @@ -0,0 +1,368 @@ +package vod + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "log" + "log/slog" + "mime" + "mime/multipart" + "net/textproto" + "os" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/google/uuid" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + "inno-live-server/internal/auth" +) + +func TestSanitizeOriginalFilename(t *testing.T) { + for _, tc := range []struct{ name, input, want string }{ + {"plain", "video.mp4", "video.mp4"}, + {"path", "/private/../video.mp4", "video.mp4"}, + {"backslash", `C:\private\video.mp4`, "video.mp4"}, + {"mixed", `/private\folder/video.mp4`, "video.mp4"}, + {"controls", " \x00vi\n\r\tdeo\x7f\u0085.mp4\x01 ", "video.mp4"}, + {"spaces", "\u2003 영상 이름.mp4 \u2003", "영상 이름.mp4"}, + {"invalid_utf8", "video\xff\xfe.mp4", "video\uFFFD.mp4"}, + {"ascii_limit", strings.Repeat("a", 256), strings.Repeat("a", 255)}, + {"exact_limit", strings.Repeat("a", 255), strings.Repeat("a", 255)}, + {"multibyte", strings.Repeat("가", 86), strings.Repeat("가", 85)}, + {"split_multibyte", strings.Repeat("a", 254) + "가.mp4", strings.Repeat("a", 254)}, + {"split_emoji", strings.Repeat("a", 253) + "😀.mp4", strings.Repeat("a", 253)}, + {"trim_after_cut", strings.Repeat("a", 254) + " b", strings.Repeat("a", 254)}, + {"empty", "", ""}, {"only_controls", "\x00\n\t ", ""}, + {"dot", "dir/ . ", ""}, {"dotdot", `dir\..`, ""}, + {"trailing_slash", "dir/", ""}, {"trailing_backslash", `dir\`, ""}, + } { + t.Run(tc.name, func(t *testing.T) { + got := sanitizeOriginalFilename(tc.input) + if tc.want == "" { + if got != nil { + t.Fatal("empty filename must be nil") + } + return + } + if got == nil || *got != tc.want || len(*got) > 255 || !utf8.ValidString(*got) { + t.Fatalf("unexpected sanitized filename: %v", got) + } + }) + } +} + +func filenameUpload(t *testing.T, filename string) (*bytes.Buffer, string) { + t.Helper() + b := new(bytes.Buffer) + m := multipart.NewWriter(b) + headers := textproto.MIMEHeader{} + headers.Set("Content-Disposition", mime.FormatMediaType("form-data", map[string]string{"name": "file", "filename": filename})) + headers.Set("Content-Type", "video/mp4") + p, err := m.CreatePart(headers) + if err != nil { + t.Fatal(err) + } + if _, err = p.Write([]byte("video")); err != nil { + t.Fatal(err) + } + if err = m.Close(); err != nil { + t.Fatal(err) + } + return b, m.FormDataContentType() +} + +func assertFilenameJSON(t *testing.T, body []byte, want string) { + t.Helper() + var v map[string]json.RawMessage + if err := json.Unmarshal(body, &v); err != nil { + t.Fatal(err) + } + value, exists := v["original_filename"] + if want == "" { + if exists { + t.Fatal("filename must be omitted, not null or empty") + } + return + } + var got string + if !exists || json.Unmarshal(value, &got) != nil || got != want { + t.Fatal("unexpected response filename") + } +} + +func assertFilenameNULL(t *testing.T, s *Service, j Job) { + t.Helper() + var erased bool + if err := s.store.Load().DB.Raw("SELECT original_filename IS NULL FROM vod_jobs WHERE id = ?", j.ID).Scan(&erased).Error; err != nil { + t.Fatal(err) + } + if !erased { + t.Fatal("filename was not SQL NULL") + } +} + +func TestOriginalFilenameAPI(t *testing.T) { + for _, tc := range []struct{ name, input, want string }{ + {"unix", "../../private.mp4", "private.mp4"}, + {"windows", `C:\private\video.mp4`, "video.mp4"}, + {"controls", "\x00 private\n.mp4 \x7f", "private.mp4"}, + {"unicode", "영상.mp4", "영상.mp4"}, + {"invalid_utf8", "video\xff.mp4", "video\uFFFD.mp4"}, + {"long", strings.Repeat("가", 86), strings.Repeat("가", 85)}, + {"empty", "", ""}, {"spaces", " ", ""}, {"dot", ".", ""}, + {"dotdot", "..", ""}, {"trailing_separator", "private/", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + s := testService(t) + s.Probe = fakeProbe{duration: 60} + h := handler(s) + user := uuid.New() + b, ct := filenameUpload(t, tc.input) + w := request(t, h, "POST", "/vod/jobs", user, b, ct) + if w.Code != 201 { + t.Fatalf("upload status %d", w.Code) + } + assertFilenameJSON(t, w.Body.Bytes(), tc.want) + jobs, err := s.store.Load().All(context.Background()) + if err != nil || len(jobs) != 1 { + t.Fatal("missing job", err) + } + j := jobs[0] + if tc.want == "" { + assertFilenameNULL(t, s, j) + } else if j.OriginalFilename == nil || *j.OriginalFilename != tc.want { + t.Fatal("filename not persisted") + } + w = request(t, h, "GET", "/vod/jobs/"+j.ID.String(), user, nil, "") + if w.Code != 200 { + t.Fatal(w.Code) + } + assertFilenameJSON(t, w.Body.Bytes(), tc.want) + w = request(t, h, "GET", "/vod/jobs", user, nil, "") + var page struct{ Jobs []json.RawMessage } + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &page) != nil || len(page.Jobs) != 1 { + t.Fatal("invalid list") + } + assertFilenameJSON(t, page.Jobs[0], tc.want) + }) + } +} + +func TestOriginalFilenameRetentionAndLogs(t *testing.T) { + for _, retention := range []time.Duration{7 * 24 * time.Hour, time.Hour} { + t.Run(retention.String(), func(t *testing.T) { + s := testService(t) + s.Config.ResultRetention = retention + now := time.Date(2026, 10, 26, 0, 0, 0, 0, time.UTC) + s.Now = func() time.Time { return now } + var logs bytes.Buffer + s.Logger = slog.New(slog.NewJSONHandler(&logs, nil)) + // NewStore must suppress SQL logging even if its caller enables it. + db := s.store.Load().DB.Session(&gorm.Session{Logger: gormlogger.New(log.New(&logs, "", 0), gormlogger.Config{LogLevel: gormlogger.Info})}) + s.SetStore(NewStore(db)) + s.Probe = fakeProbe{duration: 60} + const private = "private-customer-identity.mp4" + b, ct := filenameUpload(t, "private-directory/"+private) + h := handler(s) + user := uuid.New() + if w := request(t, h, "POST", "/vod/jobs", user, b, ct); w.Code != 201 { + t.Fatal(w.Code) + } + jobs, err := s.store.Load().All(context.Background()) + if err != nil || len(jobs) != 1 { + t.Fatal("missing job", err) + } + j := jobs[0] + s.Finish(&j, "done") + saveJob(t, s, &j) + writeFile(t, s.Path(j, "result")) + if j.ResultExpiresAt.Sub(*j.CompletedAt) != retention { + t.Fatal("wrong retention") + } + now = j.ResultExpiresAt.Add(-time.Nanosecond) + if err = s.Sweep(context.Background()); err != nil { + t.Fatal(err) + } + stored, err := s.store.Load().Get(context.Background(), j.ID, j.UserID) + if err != nil || stored.OriginalFilename == nil { + t.Fatal("early filename erasure", err) + } + w := request(t, h, "GET", "/vod/jobs/"+j.ID.String()+"/result", user, nil, "") + if w.Code != 200 || w.Header().Get("Content-Disposition") != fmt.Sprintf(`attachment; filename="vod-%s.mp4"`, j.ID) { + t.Fatal("download filename changed") + } + now = *j.ResultExpiresAt + w = request(t, h, "GET", "/vod/jobs/"+j.ID.String(), user, nil, "") + if w.Code != 200 { + t.Fatal(w.Code) + } + assertFilenameJSON(t, w.Body.Bytes(), "") + stale := stored + if err = s.Sweep(context.Background()); err != nil { + t.Fatal(err) + } + assertFilenameNULL(t, s, j) + if _, err = os.Stat(s.Path(j, "result")); !os.IsNotExist(err) { + t.Fatal("result retained") + } + if err = s.store.Load().Save(context.Background(), &stale); !errors.Is(err, ErrConflict) { + t.Fatal("stale write restored filename", err) + } + if err = s.Sweep(context.Background()); err != nil { + t.Fatal(err) + } + var audit []string + if err = s.store.Load().DB.Raw("SELECT row_to_json(l)::text FROM vod_deletion_logs l WHERE job_id = ?", j.ID).Scan(&audit).Error; err != nil { + t.Fatal(err) + } + if len(audit) != 4 || !strings.Contains(logs.String(), "vod_file_destroyed") { + t.Fatal("missing deletion evidence") + } + for _, evidence := range []string{logs.String(), strings.Join(audit, "\n")} { + if strings.Contains(evidence, private) || strings.Contains(evidence, "private-directory") || strings.Contains(evidence, "original_filename") { + t.Fatal("filename leaked into logs") + } + } + }) + } +} + +func TestOriginalFilenameErasedOnTermination(t *testing.T) { + for _, state := range []string{"delete", "cancelled", "failed"} { + t.Run(state, func(t *testing.T) { + s := testService(t) + j := createJob(t, s, "processing") + j.OriginalFilename = sanitizeOriginalFilename("private.mp4") + j.WorkerTarget = "unreachable" + saveJob(t, s, &j) + stale := j + if state == "delete" { + if w := request(t, handler(s), "DELETE", "/vod/jobs/"+j.ID.String(), j.UserID, nil, ""); w.Code != 204 { + t.Fatal(w.Code) + } + } else { + s.Finish(&j, state) + saveJob(t, s, &j) + } + assertFilenameNULL(t, s, j) + if err := s.store.Load().Save(context.Background(), &stale); !errors.Is(err, ErrConflict) { + t.Fatal("stale write accepted", err) + } + }) + } +} + +func TestOriginalFilenameErasedDespitePurgeFailure(t *testing.T) { + s := testService(t) + j := createJob(t, s, "processing") + j.OriginalFilename = sanitizeOriginalFilename("private.mp4") + s.Finish(&j, "done") + saveJob(t, s, &j) + s.Now = func() time.Time { return *j.ResultExpiresAt } + // An inaccessible root prevents result deletion, but cannot extend metadata retention. + s.Config.Root = s.Config.Root + "/missing" + if err := s.Sweep(context.Background()); err == nil { + t.Fatal("expected purge failure") + } + assertFilenameNULL(t, s, j) +} + +func TestOriginalFilenameAccountWithdrawal(t *testing.T) { + s := testService(t) + db := s.store.Load().DB + if err := db.AutoMigrate(&auth.User{}, &auth.EmailAccount{}, &auth.OAuthAccount{}, &auth.RefreshSession{}, &auth.StreamingAccount{}); err != nil { + t.Fatal(err) + } + j := createJob(t, s, "processing") + j.OriginalFilename = sanitizeOriginalFilename("private.mp4") + saveJob(t, s, &j) + other := createJob(t, s, "queued") + other.OriginalFilename = sanitizeOriginalFilename("other.mp4") + saveJob(t, s, &other) + if err := db.Create(&auth.User{ID: j.UserID, Status: auth.UserStatusActive}).Error; err != nil { + t.Fatal(err) + } + // A failed withdrawal rolls back metadata erasure along with account deletion. + if err := db.Exec("CREATE TABLE withdrawal_guard (user_id uuid REFERENCES users(id))").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO withdrawal_guard VALUES (?)", j.UserID).Error; err != nil { + t.Fatal(err) + } + withdrawal, err := auth.NewAccountWithdrawalService(auth.NewGormWithdrawalAccountStore(db), nil, nil, nil) + if err != nil { + t.Fatal(err) + } + s.Config.Enabled = false + if err = withdrawal.Withdraw(context.Background(), j.UserID); err == nil { + t.Fatal("expected rollback") + } + got, err := s.store.Load().Get(context.Background(), j.ID, j.UserID) + if err != nil || got.OriginalFilename == nil || got.Version != j.Version { + t.Fatal("erasure escaped rollback", err) + } + if err = db.Exec("DROP TABLE withdrawal_guard").Error; err != nil { + t.Fatal(err) + } + if err = withdrawal.Withdraw(context.Background(), j.UserID); err != nil { + t.Fatal(err) + } + assertFilenameNULL(t, s, j) + if err = s.store.Load().Save(context.Background(), &j); !errors.Is(err, ErrConflict) { + t.Fatal("withdrawal allowed stale write", err) + } + got, err = s.store.Load().Get(context.Background(), other.ID, other.UserID) + if err != nil || got.OriginalFilename == nil || *got.OriginalFilename != "other.mp4" { + t.Fatal("other user's filename changed", err) + } +} + +func TestOriginalFilenameMigration(t *testing.T) { + s := testService(t) + db := s.store.Load().DB + up, err := os.ReadFile("../database/migration/sql/000019_add_vod_original_filename.up.sql") + if err != nil { + t.Fatal(err) + } + if string(up) != filenameSQL { + t.Fatal("auto/versioned filename drift") + } + down, err := os.ReadFile("../database/migration/sql/000019_add_vod_original_filename.down.sql") + if err != nil { + t.Fatal(err) + } + j := createJob(t, s, "queued") + j.OriginalFilename = sanitizeOriginalFilename("private.mp4") + saveJob(t, s, &j) + for i := 0; i < 2; i++ { + if err = db.Exec(string(down)).Error; err != nil { + t.Fatal(err) + } + } + if db.Migrator().HasColumn(&Job{}, "original_filename") { + t.Fatal("column survived rollback") + } + var count int64 + if err = db.Table("vod_jobs").Where("id = ?", j.ID).Count(&count).Error; err != nil || count != 1 { + t.Fatal("rollback deleted job", err) + } + for i := 0; i < 2; i++ { + if err = db.Exec(string(up)).Error; err != nil { + t.Fatal(err) + } + } + assertFilenameNULL(t, s, j) + if err = db.Exec(string(down)).Error; err != nil { + t.Fatal(err) + } + if err = AutoMigrate(context.Background(), db); err != nil { + t.Fatal(err) + } + assertFilenameNULL(t, s, j) +} diff --git a/internal/vod/http.go b/internal/vod/http.go index 70327f1..8afd798 100644 --- a/internal/vod/http.go +++ b/internal/vod/http.go @@ -9,6 +9,7 @@ import ( "errors" "fmt" "io" + "mime" "net/http" "os" "path/filepath" @@ -115,7 +116,11 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) { } return } - if part.FormName() != "file" || part.FileName() == "" { + // Read the raw parameter: Part.FileName applies OS-specific path cleaning + // and can turn a trailing separator into a nonempty parent directory name. + _, disposition, err := mime.ParseMediaType(part.Header.Get("Content-Disposition")) + filename, hasFilename := disposition["filename"] + if err != nil || part.FormName() != "file" || !hasFilename { fail(w, 400, "bad_request") return } @@ -126,6 +131,7 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) { } now := s.Now() j := Job{ID: uuid.New(), UserID: user, Status: "uploaded", CreatedAt: now, UpdatedAt: now, QueuedAt: now, Version: 1} + j.OriginalFilename = sanitizeOriginalFilename(filename) path := s.Path(j, "original") dir := filepath.Dir(path) if e = os.MkdirAll(dir, 0750); e != nil { diff --git a/internal/vod/service.go b/internal/vod/service.go index e11ebee..1f7ecf6 100644 --- a/internal/vod/service.go +++ b/internal/vod/service.go @@ -121,6 +121,9 @@ func (s *Service) Path(j Job, kind string) string { func (s *Service) Finish(j *Job, state string) { now := s.Now() j.Status = state + if j.filenameExpired(now) { + j.OriginalFilename = nil + } j.UpdatedAt = now if j.CompletedAt == nil { j.CompletedAt = &now @@ -138,6 +141,7 @@ func (s *Service) Finish(j *Job, state string) { type View struct { ID string `json:"job_id"` + OriginalFilename *string `json:"original_filename,omitempty"` Status string `json:"status"` Progress int `json:"progress"` QueuePosition *int `json:"queue_position"` @@ -156,6 +160,9 @@ type Failure struct { func (s *Service) View(j Job, queue []Job) View { v := View{ID: j.ID.String(), Status: j.Status, Progress: j.Progress, CreatedAt: j.CreatedAt, UpdatedAt: j.UpdatedAt, CompletedAt: j.CompletedAt, OriginalDeleteAt: j.OriginalDeleteAt, ResultExpiresAt: j.ResultExpiresAt} + if !j.filenameExpired(s.Now()) { + v.OriginalFilename = j.OriginalFilename + } if j.ResultExpiresAt != nil && !s.Now().Before(*j.ResultExpiresAt) && j.Status == "done" { v.Status = "expired" } diff --git a/internal/vod/store.go b/internal/vod/store.go index 65b46b3..79fa0a8 100644 --- a/internal/vod/store.go +++ b/internal/vod/store.go @@ -15,8 +15,16 @@ import ( //go:embed schema.sql var schemaSQL string +//go:embed filename.sql +var filenameSQL string + func AutoMigrate(ctx context.Context, db *gorm.DB) error { - return db.WithContext(ctx).Exec(schemaSQL).Error + return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Exec(schemaSQL).Error; err != nil { + return err + } + return tx.Exec(filenameSQL).Error + }) } type Job struct { @@ -30,6 +38,7 @@ type Job struct { CompletedAt *time.Time OriginalDeleteAt *time.Time ResultExpiresAt *time.Time + OriginalFilename *string DeletedAt *time.Time DurationSec float64 FramesDone uint64 diff --git a/internal/vod/sweeper.go b/internal/vod/sweeper.go index 932ea59..54d3439 100644 --- a/internal/vod/sweeper.go +++ b/internal/vod/sweeper.go @@ -28,6 +28,18 @@ func (s *Service) Sweep(ctx context.Context) error { } var errs []error for _, j := range jobs { + // Metadata erasure must not wait for worker acknowledgement or successful + // filesystem cleanup. CAS also prevents stale worker writes restoring it. + if j.OriginalFilename != nil && j.filenameExpired(s.Now()) { + j.OriginalFilename = nil + j.UpdatedAt = s.Now() + if err := db.Save(ctx, &j); err != nil { + if !errors.Is(err, ErrConflict) { + errs = append(errs, err) + } + continue + } + } if !j.Terminal() { continue } From 9ac8c03088d66dd1c223a38621b3cbebd24534e2 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:43:39 +0900 Subject: [PATCH 14/23] =?UTF-8?q?fix:=20VOD=20=EC=9B=8C=EC=BB=A4=20?= =?UTF-8?q?=ED=95=9C=EB=8F=84=EC=99=80=20=EC=84=9C=EB=B2=84=20=EA=B8=B0?= =?UTF-8?q?=EB=B3=B8=EA=B0=92=20=EC=A0=95=ED=95=A9=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 6 ++++ api/vod/README.md | 3 ++ internal/config/vod.go | 15 ++++++++- internal/config/vod_test.go | 23 ++++++++++++++ internal/vod/http.go | 29 +++++++++++++++-- internal/vod/upload_limit_test.go | 52 +++++++++++++++++++++++++++++++ internal/vod/vod_test.go | 8 ++++- 7 files changed, 131 insertions(+), 5 deletions(-) create mode 100644 internal/vod/upload_limit_test.go diff --git a/.env.example b/.env.example index 1a23307..87a7d69 100644 --- a/.env.example +++ b/.env.example @@ -206,3 +206,9 @@ AUTH_EMAIL_PENDING_USER_TTL=30m # Web experience collector, server-to-server only. Empty disables ingestion. # Set the same random 32+ character key in the landing runtime; never NEXT_PUBLIC. EXPERIENCE_QUALITY_INGEST_KEY= + +# VOD is disabled by default. When enabled, limits above the fixed AI worker +# maxima are startup errors; smaller positive limits are allowed. +VOD_ENABLED=false +VOD_MAX_UPLOAD_BYTES=2147483648 +VOD_MAX_DURATION_SEC=3600 diff --git a/api/vod/README.md b/api/vod/README.md index 6782bb6..01603be 100644 --- a/api/vod/README.md +++ b/api/vod/README.md @@ -29,6 +29,9 @@ | `VOD_SWEEP_INTERVAL` | `1m` | 정리 재시도·고아 업로드 탐색 주기 | | `VOD_UPLOAD_TIMEOUT` | `30m` | 업로드 읽기 및 미디어 검사 기한 | +- 활성화 시 `VOD_MAX_UPLOAD_BYTES > 2147483648` 또는 `VOD_MAX_DURATION_SEC > 3600`은 시작 오류; 더 작은 양의 상한은 허용 +- 워커의 고정 상한과 일치하도록 기본값 유지; 설정을 조용히 낮추면 지정값과 실제 허용량이 달라지므로 시작 시 명시적 거부 선택 +- 파일 바이트 상한까지 저장 후 초과 판별용 1바이트만 읽고 즉시 중단; 초과 바이트는 파일에 기록하지 않으며 multipart 파서의 제한된 read-ahead 외 남은 본문 미소비 - GPU당 VOD 1건은 고정 정책; 같은 GPU의 다른 워커에 라이브가 있어도 VOD 배정 금지 - 모든 현재 풀 타깃의 GPU UUID 매핑 필요; 누락된 교체 슬롯이나 GPU 메모리 조회 실패 시 VOD 배정 중단 - `nvidia-smi --query-gpu=uuid,memory.free --format=csv,noheader,nounits`로 실제 여유 메모리 조회 diff --git a/internal/config/vod.go b/internal/config/vod.go index 9456d40..7b85c7d 100644 --- a/internal/config/vod.go +++ b/internal/config/vod.go @@ -9,6 +9,12 @@ import ( "time" ) +// The AI worker enforces these fixed MVP limits, including on resume. +const ( + VODWorkerMaxUploadBytes int64 = 2147483648 + VODWorkerMaxDuration = 3600 * time.Second +) + // GPU identity must identify a physical GPU across all slots on a host. // Unknown targets fail closed for VOD; live target replacement remains unchanged. type VODConfig struct { @@ -26,7 +32,7 @@ type VODConfig struct { } func DefaultVOD() VODConfig { - return VODConfig{Root: "/var/lib/innolive/vod", MaxUploadBytes: 2147483648, MaxDuration: time.Hour, + return VODConfig{Root: "/var/lib/innolive/vod", MaxUploadBytes: VODWorkerMaxUploadBytes, MaxDuration: VODWorkerMaxDuration, Concurrency: 1, MinFreeMiB: 4096, ResultRetention: 7 * 24 * time.Hour, OriginalRetention: 24 * time.Hour, SweepInterval: time.Minute, UploadTimeout: 30 * time.Minute, WorkerGPUs: map[string]string{}} } @@ -100,6 +106,13 @@ func (c VODConfig) Validate() error { if !c.Enabled { return nil } + // Reject misconfiguration instead of silently advertising a different limit. + if c.MaxUploadBytes > VODWorkerMaxUploadBytes { + return fmt.Errorf("VOD_MAX_UPLOAD_BYTES must not exceed %d", VODWorkerMaxUploadBytes) + } + if c.MaxDuration > VODWorkerMaxDuration { + return fmt.Errorf("VOD_MAX_DURATION_SEC must not exceed 3600") + } if !filepath.IsAbs(c.Root) || filepath.Clean(c.Root) == "/" { return fmt.Errorf("VOD_ROOT must be an absolute dedicated directory") } diff --git a/internal/config/vod_test.go b/internal/config/vod_test.go index de92098..2066058 100644 --- a/internal/config/vod_test.go +++ b/internal/config/vod_test.go @@ -28,3 +28,26 @@ func TestVODEnabledValidation(t *testing.T) { }) } } + +func TestVODWorkerLimitValidation(t *testing.T) { + for _, tc := range []struct { + name, bytes, seconds string + valid bool + }{ + {"defaults", "", "", true}, + {"exact", "2147483648", "3600", true}, + {"lower", "2147483647", "3599", true}, + {"bytes_plus_one", "2147483649", "3600", false}, + {"seconds_plus_one", "2147483648", "3601", false}, + {"zero_duration", "2147483648", "0", false}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("VOD_ENABLED", "true") + t.Setenv("VOD_MAX_UPLOAD_BYTES", tc.bytes) + t.Setenv("VOD_MAX_DURATION_SEC", tc.seconds) + if _, err := loadVOD(); (err == nil) != tc.valid { + t.Fatalf("valid=%v, error=%v", tc.valid, err) + } + }) + } +} diff --git a/internal/vod/http.go b/internal/vod/http.go index 8afd798..46a251d 100644 --- a/internal/vod/http.go +++ b/internal/vod/http.go @@ -151,11 +151,14 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) { fail(w, 503, "service_unavailable") return } - n, e := io.Copy(f, io.LimitReader(part, maxBytes+1)) - syncErr := f.Sync() + n, e := copyUpload(f, part, maxBytes) + var syncErr error + if e == nil { + syncErr = f.Sync() + } closeErr := f.Close() var capErr *http.MaxBytesError - if n > maxBytes || errors.As(e, &capErr) { + if errors.Is(e, errUploadTooLarge) || errors.As(e, &capErr) { tooLarge() return } @@ -435,3 +438,23 @@ func (s *Service) result(w http.ResponseWriter, r *http.Request) { } } } + +// Read at most one extra byte to detect overflow, without writing it or draining +// the remainder. Multipart parsing may buffer a bounded amount ahead of us. +var errUploadTooLarge = errors.New("upload too large") + +func copyUpload(dst io.Writer, src io.Reader, limit int64) (int64, error) { + n, err := io.Copy(dst, io.LimitReader(src, limit)) + if err != nil { + return n, err + } + var extra [1]byte + count, err := io.ReadFull(src, extra[:]) + if count != 0 { + return n, errUploadTooLarge + } + if err == io.EOF { + err = nil + } + return n, err +} diff --git a/internal/vod/upload_limit_test.go b/internal/vod/upload_limit_test.go new file mode 100644 index 0000000..31abc92 --- /dev/null +++ b/internal/vod/upload_limit_test.go @@ -0,0 +1,52 @@ +package vod + +import ( + "errors" + "io" + "testing" + + "inno-live-server/internal/config" +) + +type countedInput struct{ remaining, read int64 } + +func (r *countedInput) Read(p []byte) (int, error) { + if r.remaining == 0 { + return 0, io.EOF + } + n := min(int64(len(p)), r.remaining) + clear(p[:n]) + r.remaining -= n + r.read += n + return int(n), nil +} + +type countedOutput int64 + +func (w *countedOutput) Write(p []byte) (int, error) { *w += countedOutput(len(p)); return len(p), nil } + +func TestUploadStreamingWorkerByteBoundary(t *testing.T) { + for _, tc := range []struct { + name string + extra int64 + }{{"exact", 0}, {"plus_one", 1}, {"unbounded_tail", 1 << 30}} { + t.Run(tc.name, func(t *testing.T) { + limit := config.VODWorkerMaxUploadBytes + src := &countedInput{remaining: limit + tc.extra} + var dst countedOutput + n, err := copyUpload(&dst, src, limit) + if n != limit || int64(dst) != limit { + t.Fatalf("written=%d returned=%d", dst, n) + } + if tc.extra == 0 && err != nil { + t.Fatal(err) + } + if tc.extra > 0 && !errors.Is(err, errUploadTooLarge) { + t.Fatal(err) + } + if src.read != limit+min(tc.extra, 1) { + t.Fatalf("read beyond overflow byte: %d", src.read) + } + }) + } +} diff --git a/internal/vod/vod_test.go b/internal/vod/vod_test.go index 3631cbe..afee274 100644 --- a/internal/vod/vod_test.go +++ b/internal/vod/vod_test.go @@ -199,7 +199,7 @@ func TestUploadLimitsAndValidation(t *testing.T) { duration float64 err error code int - }{{"accepted", 10, 1, 60, nil, 201}, {"byte_boundary", 32, 1, 3600, nil, 201}, {"bytes", 33, 1, 60, nil, 413}, {"duration", 10, 1, 3600.1, nil, 422}, {"empty", 0, 1, 60, nil, 400}, {"duplicates", 10, 2, 60, nil, 400}, {"unsupported", 10, 1, 60, ErrUnsupported, 415}, {"corrupt", 10, 1, 0, ErrInvalidMedia, 422}} + }{{"accepted", 10, 1, 60, nil, 201}, {"byte_boundary", 32, 1, 3600, nil, 201}, {"bytes", 33, 1, 60, nil, 413}, {"duration", 10, 1, 3601, nil, 422}, {"fractional_duration", 10, 1, 3600.1, nil, 422}, {"empty", 0, 1, 60, nil, 400}, {"duplicates", 10, 2, 60, nil, 400}, {"unsupported", 10, 1, 60, ErrUnsupported, 415}, {"corrupt", 10, 1, 0, ErrInvalidMedia, 422}} for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { s := testService(t) @@ -215,6 +215,12 @@ func TestUploadLimitsAndValidation(t *testing.T) { if w.Code != tc.code { t.Fatalf("%d: %s", w.Code, w.Body.String()) } + if tc.code == 413 && !strings.Contains(w.Body.String(), `"code":"upload_too_large"`) { + t.Fatal(w.Body.String()) + } + if (tc.name == "duration" || tc.name == "fractional_duration") && !strings.Contains(w.Body.String(), `"code":"duration_exceeded"`) { + t.Fatal(w.Body.String()) + } jobs, e := s.store.Load().All(context.Background()) if e != nil { t.Fatal(e) From a8a2ebb642a9a51a872a83c29e5a3f1553bb4a5c Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:50:33 +0900 Subject: [PATCH 15/23] =?UTF-8?q?feat:=20VOD=20=EC=9B=8C=EC=BB=A4=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=BD=94=EB=93=9C=EB=A5=BC=20API=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A1=9C=20=EB=A7=A4=ED=95=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/openapi.yaml | 60 +++++++-- internal/ai/vod.go | 6 +- internal/vod/http.go | 8 ++ internal/vod/scheduler.go | 81 ++++++++---- internal/vod/scheduler_test.go | 151 +++++++++++++++++++++- internal/vod/service.go | 4 +- internal/vod/worker_errors.go | 77 +++++++++++ internal/vod/worker_errors_test.go | 198 +++++++++++++++++++++++++++++ internal/vod/worker_test.go | 16 ++- 9 files changed, 551 insertions(+), 50 deletions(-) create mode 100644 internal/vod/worker_errors.go create mode 100644 internal/vod/worker_errors_test.go diff --git a/api/vod/openapi.yaml b/api/vod/openapi.yaml index 3968cbd..4f63e5f 100644 --- a/api/vod/openapi.yaml +++ b/api/vod/openapi.yaml @@ -1,7 +1,7 @@ openapi: 3.0.3 info: title: InnoLive VOD 작업 API - version: 0.2.0 + version: 0.3.0 description: | # 범위와 근거 @@ -49,6 +49,7 @@ info: 0은 종료 직후 파기이다. 스위퍼 주기를 더해 기한을 넘겨서는 안 된다. - VOD_RESULT_RETENTION_SEC: 성공 완료 후 결과 보존 기간, 양의 정수 초. 무기한 보관이나 다운로드 시 기한 연장은 허용하지 않는다. + VOD 활성화 시 M > 2147483648 또는 D > 3600 설정은 시작 오류이며 더 작은 양의 값은 허용한다. 기본값은 M=2147483648(2 GiB), D=3600(60분), 결과 보존 기간=604800초(7일)이다. 상한 초과 오류 응답의 details에 적용 상한(max_upload_bytes, max_request_bytes, max_duration_sec)을 담는다. 설정 변경은 이미 확정된 삭제/만료 기한을 연장하지 않는다. @@ -101,6 +102,26 @@ info: done/expired는 100, failed는 마지막 값이다. 목록/상세를 5초 간격으로 폴링한다. 상태 변화가 빠르면 중간 상태를 관찰하지 못할 수 있다. + # 워커 오류와 비동기 응답 + + POST 201 뒤 발생한 워커 오류는 목록/상세의 HTTP 200 작업 상태와 failure.code로 전달한다. + GET result는 failed/cancelled 작업에 아래 HTTP 오류를 반환하며 워커 원문 메시지는 노출하지 않는다. + - INPUT_TOO_LARGE → failed, 413/upload_too_large; INPUT_TOO_LONG → failed, 422/duration_exceeded. + - INPUT_UNREADABLE, DECODER_FAILED → failed, 422/invalid_media; INPUT_UNSUPPORTED → failed, 415/unsupported_media_type. + - INPUT_NOT_FOUND, DISK_FULL → failed, 503/storage_failed (서버 공유 저장소 문제; 작업 자체는 terminal). + - PATH_OUTSIDE_ROOT, OUTPUT_EXISTS, CHECKPOINT_INVALID, CHECKPOINT_MISMATCH, + EMBEDDING_MODEL_MISMATCH, WHITELIST_SESSION_NOT_FOUND, ENCODER_FAILED, MUX_FAILED, + INTERNAL, 알 수 없는 잡 코드 → failed, 500/processing_failed. + - CANCELLED → cancelled, 409/job_cancelled. 삭제된 작업은 계속 404/not_found. + - RUNTIME_FAILED → paused, 다른 워커에서 체크포인트 재개; failure=null, result는 409/result_not_ready. + - RPC INVALID_ARGUMENT → failed, 400/bad_request; FAILED_PRECONDITION, UNIMPLEMENTED → failed, 422/processing_unsupported. + - RPC RESOURCE_EXHAUSTED → paused로 큐·순번 유지 후 재시도; HTTP 429/413 사용자 오류로 변환하지 않는다. + - RPC NOT_FOUND → paused 후 재개. ALREADY_EXISTS, UNAVAILABLE, DEADLINE_EXCEEDED, + CANCELLED는 processing에서 Get/Pause로 실제 상태 확인 후 반영하며 NOT_FOUND 확인 시 paused. + - 알 수 없는 RPC 오류도 먼저 상태 확인; NOT_FOUND 확인 시 failed, 500/processing_failed. + 확인되지 않은 실행을 중복 시작하거나 파일을 파기하지 않는다. + - processing/paused/queued의 failure는 null, 결과 요청은 409/result_not_ready. + # 보존과 시간 모든 *_at은 UTC RFC 3339 문자열(Z, 소수 초 허용)이다. created_at/updated_at은 @@ -152,7 +173,10 @@ info: type VodJobStatus = | 'uploaded' | 'queued' | 'processing' | 'paused' | 'done' | 'failed' | 'cancelled' | 'expired'; - type VodFailureCode = 'processing_failed' | 'storage_failed'; + type VodFailureCode = + | 'bad_request' | 'upload_too_large' | 'duration_exceeded' | 'invalid_media' + | 'unsupported_media_type' | 'processing_unsupported' | 'job_cancelled' + | 'processing_failed' | 'storage_failed'; interface VodJob { job_id: string; original_filename?: string; @@ -381,7 +405,8 @@ paths: summary: 비식별 결과 파일 다운로드 description: | 본인 소유이며 done이고 현재 시각 < result_expires_at인 작업만 다운로드 가능하다. - uploaded/queued/processing/paused/failed/cancelled는 409/result_not_ready, + uploaded/queued/processing/paused는 409/result_not_ready, failed는 failure.code에 대응하는 오류, + cancelled는 409/job_cancelled, expired 또는 만료 시각 경과는 410/result_expired이다. 타인 작업/삭제된 작업/없는 작업은 상태보다 먼저 판정하여 404이다. 만료 전 파일을 일시적으로 읽을 수 없으면 503/service_unavailable이며 원본으로 대체하지 않는다. @@ -429,6 +454,12 @@ paths: $ref: '#/components/responses/ResultNotReady' '410': $ref: '#/components/responses/ResultExpired' + '413': + $ref: '#/components/responses/UploadTooLarge' + '415': + $ref: '#/components/responses/UnsupportedMedia' + '422': + $ref: '#/components/responses/InvalidMedia' '429': $ref: '#/components/responses/RateLimited' '500': @@ -541,12 +572,11 @@ components: failure: type: object nullable: true - description: failed에서만 오류 객체, 그 외 null. 내부 경로/워커 주소/민감 데이터 미포함. + description: failed/cancelled의 오류 객체, 그 외 null. 내부 경로/워커 주소/민감 데이터 미포함. required: [code, message] properties: code: - type: string - enum: [processing_failed, storage_failed] + $ref: '#/components/schemas/VodFailureCode' message: type: string example: @@ -562,6 +592,9 @@ components: original_delete_at: null result_expires_at: null failure: null + VodFailureCode: + type: string + enum: [bad_request, upload_too_large, duration_exceeded, invalid_media, unsupported_media_type, processing_unsupported, job_cancelled, processing_failed, storage_failed] VodJobList: type: object required: [jobs, next_cursor] @@ -682,7 +715,8 @@ components: $ref: '#/components/headers/NoStore' description: | upload_too_large — 파일 크기 또는 전체 요청 본문 상한 초과. - details.max_upload_bytes와 details.max_request_bytes에 적용 상한 제공. + 업로드 선검증에서 details.max_upload_bytes와 details.max_request_bytes에 적용 상한 제공. + 워커 입력 크기 거부 후 결과 요청에도 같은 코드 사용(details 생략). 예시의 수치는 운영 기본값이 아니다. content: application/json: @@ -707,7 +741,8 @@ components: $ref: '#/components/headers/NoStore' description: | invalid_media — 손상 파일, 영상 트랙 부재, 길이를 조사할 수 없거나 길이 <= 0. - duration_exceeded — 영상 길이 상한 초과. details.max_duration_sec에 적용 상한 제공. + processing_unsupported — 워커가 지원하지 않는 처리 품질/모드 또는 VOD RPC. + duration_exceeded — 영상 길이 상한 초과. details.max_duration_sec에 업로드 선검증의 적용 상한 제공(워커 오류 후 결과 요청은 details 생략). content: application/json: schema: @@ -718,7 +753,9 @@ components: $ref: '#/components/headers/RequestID' Cache-Control: $ref: '#/components/headers/NoStore' - description: result_not_ready — uploaded/queued/processing/paused/failed/cancelled 작업에 결과 요청. + description: | + result_not_ready — uploaded/queued/processing/paused 작업에 결과 요청. + job_cancelled — 취소된 작업에 결과 요청. content: application/json: schema: @@ -753,7 +790,9 @@ components: $ref: '#/components/headers/RequestID' Cache-Control: $ref: '#/components/headers/NoStore' - description: internal_error — 예상하지 못한 서버 오류 또는 기존 인증 저장소 오류. + description: | + internal_error — 예상하지 못한 서버 오류 또는 기존 인증 저장소 오류. + processing_failed — 워커 내부 오류 또는 알 수 없는 실패 코드. 내부 정보 미노출. content: application/json: schema: @@ -761,6 +800,7 @@ components: Unavailable: description: | service_unavailable — 작업 저장소/파일 저장소 등 일시적 장애. + storage_failed — 워커 입력 파일 없음/디스크 부족 또는 완료 결과 검증 실패. 해당 작업은 failed이며 자동 재실행되지 않음. demo_lockdown — 기존 RequireUser 시연 제한 정책. 라이브 자원 점유 자체는 503 사유가 아님. headers: X-Request-ID: diff --git a/internal/ai/vod.go b/internal/ai/vod.go index db22c95..a469c64 100644 --- a/internal/ai/vod.go +++ b/internal/ai/vod.go @@ -1,6 +1,7 @@ package ai import ( + "slices" "sync" ) @@ -24,7 +25,7 @@ func (l *VODLease) Release() { // ReserveVOD and live claim use the same mutex. VOD never changes live counters // or ranking. Preemption is a channel close only: no RPC or I/O under pool.mu. -func (p *Pool) ReserveVOD(mapping map[string]string, freeMiB map[string]int64, minFree int64, limit int) *VODLease { +func (p *Pool) ReserveVOD(mapping map[string]string, freeMiB map[string]int64, minFree int64, limit int, excludedTargets ...string) *VODLease { p.mu.Lock() defer p.mu.Unlock() for _, c := range p.clients { @@ -36,6 +37,9 @@ func (p *Pool) ReserveVOD(mapping map[string]string, freeMiB map[string]int64, m return nil } for i, c := range p.clients { + if slices.Contains(excludedTargets, c.Address()) { + continue + } gpu := mapping[c.Address()] free, known := freeMiB[gpu] if gpu == "" || !known || free < minFree || p.vod[gpu] != nil || p.active[i] > 0 || p.unready[c.Address()] || p.draining[c.Address()] || p.faceSwap[c.Address()] { diff --git a/internal/vod/http.go b/internal/vod/http.go index 46a251d..287023d 100644 --- a/internal/vod/http.go +++ b/internal/vod/http.go @@ -356,6 +356,14 @@ func (s *Service) result(w http.ResponseWriter, r *http.Request) { fail(w, 410, "result_expired") return } + if j.Status == "failed" || j.Status == "cancelled" { + mapped := publicFailure(j.FailureCode) + if j.Status == "cancelled" { + mapped = mapWorkerFailure("job:CANCELLED") + } + fail(w, mapped.HTTP, mapped.Code) + return + } if j.Status != "done" || j.ResultExpiresAt == nil { fail(w, 409, "result_not_ready") return diff --git a/internal/vod/scheduler.go b/internal/vod/scheduler.go index fefda3b..12e91be 100644 --- a/internal/vod/scheduler.go +++ b/internal/vod/scheduler.go @@ -11,8 +11,6 @@ import ( "sync" "time" - "google.golang.org/grpc/codes" - "google.golang.org/grpc/status" pb "inno-live-server/api/gen/aiv1" "inno-live-server/internal/ai" ) @@ -130,6 +128,11 @@ func (e *Engine) Reconcile(ctx context.Context) error { } for _, v := range list { j, exists := known[v.JobId] + // A failed runtime can retain its terminal snapshot after this job + // resumed elsewhere. Do not restore that old checkpoint or target. + if exists && v.State == pb.VodJobState_VOD_JOB_STATE_FAILED && v.ErrorCode == "RUNTIME_FAILED" && j.WorkerTarget != target.Address { + continue + } call, cancel := context.WithTimeout(ctx, 5*time.Second) if !exists || j.DeletedAt != nil { _, err = e.Worker.Cancel(call, target.Address, v.JobId) @@ -166,11 +169,8 @@ func (e *Engine) Reconcile(ctx context.Context) error { v, err = e.Worker.Pause(call, j.WorkerTarget, j.ID.String()) if err == nil { err = e.apply(ctx, &j, v) - } else if status.Code(err) == codes.NotFound { - j.Status = "paused" - j.WorkerTarget = "" - j.UpdatedAt = e.Service.Now() - err = db.Save(ctx, &j) + } else if workerMissing(err) { + err = e.applyFailure(ctx, &j, mapRPCFailure(err), false) } } cancel() @@ -232,9 +232,13 @@ func (e *Engine) Tick(ctx context.Context) error { e.mu.Unlock() continue } - lease := e.Pool.ReserveVOD(e.Service.Config.WorkerGPUs, free, e.Service.Config.MinFreeMiB, e.Service.Config.Concurrency) + lease := e.Pool.ReserveVOD(e.Service.Config.WorkerGPUs, free, e.Service.Config.MinFreeMiB, e.Service.Config.Concurrency, j.WorkerTarget) if lease == nil { e.mu.Unlock() + if j.WorkerTarget != "" { + // This job excludes its failed worker; other jobs may still run. + continue + } break } if j.Status == "paused" { @@ -272,6 +276,7 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { // Persist target before admission: restart reconciliation can locate a job // even if the Start response or the subsequent DB write was lost. j.Status = "processing" + j.FailureCode = "" j.WorkerTarget = lease.Target j.UpdatedAt = e.Service.Now() if err := db.Save(ctx, &j); err != nil { @@ -294,17 +299,16 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { call, cancel = context.WithTimeout(ctx, 10*time.Second) v, err := e.Worker.Start(call, lease.Target, req) cancel() - if status.Code(err) == codes.ResourceExhausted || status.Code(err) == codes.FailedPrecondition || status.Code(err) == codes.InvalidArgument || status.Code(err) == codes.Unimplemented { - if status.Code(err) != codes.ResourceExhausted { - e.Service.Finish(&j, "failed") - j.FailureCode = "processing_failed" - j.WorkerTarget = "" - _ = db.Save(ctx, &j) - } else { - e.failUnstarted(ctx, &j) + var admissionFailure *workerFailure + if err != nil { + mapped := mapRPCFailure(err) + if !mapped.Reconcile { + _ = e.applyFailure(ctx, &j, mapped, false) + return } - return + admissionFailure = &mapped } + if err == nil { if saveErr := e.apply(ctx, &j, v); saveErr == nil && j.Status != "processing" { return @@ -351,6 +355,7 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { yield = true preempt = nil case v := <-updates: + admissionFailure = nil if err := e.refreshApply(ctx, &j, v); err == nil && j.Status != "processing" { return } @@ -423,14 +428,16 @@ func (e *Engine) runJob(ctx context.Context, j Job, lease *ai.VODLease) { v, err = e.Worker.Get(call, lease.Target, j.ID.String()) } c() - if status.Code(err) == codes.NotFound { - j.Status = "paused" - j.WorkerTarget = "" - j.UpdatedAt = e.Service.Now() - _ = db.Save(ctx, &j) + if workerMissing(err) { + mapped := mapRPCFailure(err) + if admissionFailure != nil && admissionFailure.State == "failed" { + mapped = *admissionFailure + } + _ = e.applyFailure(ctx, &j, mapped, false) return } if err == nil { + admissionFailure = nil if err = e.refreshApply(ctx, &j, v); err == nil && j.Status != "processing" { return } @@ -493,12 +500,9 @@ func (e *Engine) apply(ctx context.Context, j *Job, v *pb.VodJobStatus) error { } j.WorkerTarget = "" case pb.VodJobState_VOD_JOB_STATE_FAILED: - e.Service.Finish(j, "failed") - j.FailureCode = "processing_failed" - j.WorkerTarget = "" + return e.applyFailure(ctx, j, mapWorkerFailure("job:"+v.ErrorCode), v.ErrorCode == "RUNTIME_FAILED") case pb.VodJobState_VOD_JOB_STATE_CANCELLED: - e.Service.Finish(j, "cancelled") - j.WorkerTarget = "" + return e.applyFailure(ctx, j, mapWorkerFailure("job:CANCELLED"), false) default: return fmt.Errorf("unknown VOD worker state") } @@ -555,3 +559,26 @@ func (e *Engine) preempt(ctx context.Context, lease *ai.VODLease, id string) { } } } + +func (e *Engine) applyFailure(ctx context.Context, j *Job, mapped workerFailure, avoidWorker bool) error { + if j.Terminal() || j.DeletedAt != nil { + return nil + } + if mapped.State == "failed" || mapped.State == "cancelled" { + e.Service.Finish(j, mapped.State) + } else { + j.Status = mapped.State + } + j.FailureCode = mapped.Code + // A paused runtime failure retains the target solely to exclude it on retry. + // Persisting this also preserves the exclusion across server restarts. + if !avoidWorker { + j.WorkerTarget = "" + } + j.UpdatedAt = e.Service.Now() + err := e.Service.store.Load().Save(ctx, j) + if err == nil && j.Terminal() { + e.Service.WakeSweep() + } + return err +} diff --git a/internal/vod/scheduler_test.go b/internal/vod/scheduler_test.go index b8839cf..3e582c7 100644 --- a/internal/vod/scheduler_test.go +++ b/internal/vod/scheduler_test.go @@ -22,12 +22,13 @@ func (fakeMemory) FreeMiB(context.Context) (map[string]int64, error) { } type fakeWorker struct { - mu sync.Mutex - jobs map[string]*pb.VodJobStatus - starts []*pb.StartVodJobRequest - pauseGate <-chan struct{} - startErr error - remoteLive map[string]uint64 + mu sync.Mutex + jobs map[string]*pb.VodJobStatus + starts []*pb.StartVodJobRequest + pauseGate <-chan struct{} + startErr error + startFailure string + remoteLive map[string]uint64 } func newFakeWorker() *fakeWorker { return &fakeWorker{jobs: map[string]*pb.VodJobStatus{}} } @@ -39,6 +40,12 @@ func (f *fakeWorker) Start(_ context.Context, target string, r *pb.StartVodJobRe } f.starts = append(f.starts, proto.Clone(r).(*pb.StartVodJobRequest)) v := &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PROCESSING, CheckpointToken: r.CheckpointToken, Progress: &pb.VodProgress{FramesDone: 300, FramesTotal: 1800, Fraction: 1.0 / 6, ProcessingFps: 30}, WorkerTarget: target} + if f.startFailure != "" { + v.State = pb.VodJobState_VOD_JOB_STATE_FAILED + v.ErrorCode = f.startFailure + v.ErrorMessage = "private path" + v.CheckpointToken = "runtime-checkpoint" + } f.jobs[r.JobId] = v return proto.Clone(v).(*pb.VodJobStatus), nil } @@ -348,3 +355,135 @@ func TestSchedulerPostgresLeaderExclusive(t *testing.T) { t.Fatalf("multiple leaders admitted %d jobs", starts) } } + +func TestRuntimeFailureResumesOnDifferentWorker(t *testing.T) { + s, e, f, ctx := setupEngine(t) + f.startFailure = "RUNTIME_FAILED" + j := createJob(t, s, "queued") + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + e.wg.Wait() + current, err := s.store.Load().Get(ctx, j.ID, j.UserID) + if err != nil || current.Status != "paused" || current.WorkerTarget == "" || current.CompletedAt != nil { + t.Fatal(current, err) + } + failedTarget := current.WorkerTarget + // A new engine demonstrates that the excluded worker is persisted in DB. + e = NewEngine(s, e.Pool, f, fakeMemory{}) + f.mu.Lock() + f.startFailure = "" + f.mu.Unlock() + retryCtx, cancel := context.WithCancel(ctx) + defer func() { cancel(); e.wg.Wait() }() + if err := e.Tick(retryCtx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.starts) != 2 { + return false + } + req := f.starts[1] + snapshot := f.jobs[j.ID.String()] + return req.Resume && req.CheckpointToken == "runtime-checkpoint" && snapshot.WorkerTarget != failedTarget + }) +} + +func TestRPCAdmissionFailureOutcomes(t *testing.T) { + for _, tc := range []struct { + name string + code codes.Code + state, failure string + }{ + {"invalid", codes.InvalidArgument, "failed", "bad_request"}, + {"precondition", codes.FailedPrecondition, "failed", "processing_unsupported"}, + {"unimplemented", codes.Unimplemented, "failed", "processing_unsupported"}, + {"not_found", codes.NotFound, "paused", ""}, + {"unavailable", codes.Unavailable, "paused", ""}, + {"deadline", codes.DeadlineExceeded, "paused", ""}, + {"cancelled_rpc", codes.Canceled, "paused", ""}, + {"unknown", codes.Unknown, "failed", "processing_failed"}, + {"internal", codes.Internal, "failed", "processing_failed"}, + } { + t.Run(tc.name, func(t *testing.T) { + s, e, f, ctx := setupEngine(t) + f.startErr = status.Error(tc.code, "private worker path") + j := createJob(t, s, "queued") + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + e.wg.Wait() + current, err := s.store.Load().Get(ctx, j.ID, j.UserID) + if err != nil || current.Status != tc.state || current.FailureCode != tc.failure || current.WorkerTarget != "" { + t.Fatal(current, err) + } + }) + } +} + +func TestAlreadyExistsReconcilesExistingJob(t *testing.T) { + s, e, f, ctx := setupEngine(t) + j := createJob(t, s, "queued") + f.startErr = status.Error(codes.AlreadyExists, "private existing job") + f.jobs[j.ID.String()] = &pb.VodJobStatus{JobId: j.ID.String(), State: pb.VodJobState_VOD_JOB_STATE_PAUSED, CheckpointToken: "existing-checkpoint"} + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + e.wg.Wait() + current, err := s.store.Load().Get(ctx, j.ID, j.UserID) + if err != nil || current.Status != "paused" || current.CheckpointToken != "existing-checkpoint" || current.FailureCode != "" { + t.Fatal(current, err) + } +} + +func TestRuntimeFailureDoesNotBlockOtherQueuedJobs(t *testing.T) { + s, e, f, ctx := setupEngine(t) + // Only the failed target is available. The retry waits, the next job can run. + e.Pool.SetTargetReady("b:1", false) + e.Pool.SetTargetReady("c:1", false) + first := createJob(t, s, "paused") + first.WorkerTarget = "a:1" + first.CheckpointToken = "saved" + saveJob(t, s, &first) + second := createJob(t, s, "queued") + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.starts) == 1 && f.starts[0].JobId == second.ID.String() + }) + current, err := s.store.Load().Get(ctx, first.ID, first.UserID) + if err != nil || current.Status != "paused" || current.WorkerTarget != "a:1" { + t.Fatal(current, err) + } +} + +type staleRuntimeWorker struct { + *fakeWorker + id string +} + +func (w staleRuntimeWorker) List(_ context.Context, target string) ([]*pb.VodJobStatus, error) { + if target != "a:1" { + return nil, nil + } + return []*pb.VodJobStatus{{JobId: w.id, State: pb.VodJobState_VOD_JOB_STATE_FAILED, ErrorCode: "RUNTIME_FAILED", CheckpointToken: "stale"}}, nil +} +func TestReconcileIgnoresRuntimeFailureFromPreviousWorker(t *testing.T) { + s, e, f, ctx := setupEngine(t) + j := createJob(t, s, "paused") + j.CheckpointToken = "new-checkpoint" + saveJob(t, s, &j) + e.Worker = staleRuntimeWorker{f, j.ID.String()} + if err := e.Reconcile(ctx); err != nil { + t.Fatal(err) + } + current, err := s.store.Load().Get(ctx, j.ID, j.UserID) + if err != nil || current.CheckpointToken != "new-checkpoint" || current.WorkerTarget != "" { + t.Fatal(current, err) + } +} diff --git a/internal/vod/service.go b/internal/vod/service.go index 1f7ecf6..3203be0 100644 --- a/internal/vod/service.go +++ b/internal/vod/service.go @@ -166,8 +166,8 @@ func (s *Service) View(j Job, queue []Job) View { if j.ResultExpiresAt != nil && !s.Now().Before(*j.ResultExpiresAt) && j.Status == "done" { v.Status = "expired" } - if j.FailureCode != "" { - v.Failure = &Failure{Code: j.FailureCode, Message: "Video processing failed."} + if j.FailureCode != "" && (j.Status == "failed" || j.Status == "cancelled") { + v.Failure = &Failure{Code: publicFailure(j.FailureCode).Code, Message: "Video processing failed."} } var speed float64 var samples int diff --git a/internal/vod/worker_errors.go b/internal/vod/worker_errors.go new file mode 100644 index 0000000..f581680 --- /dev/null +++ b/internal/vod/worker_errors.go @@ -0,0 +1,77 @@ +package vod + +import ( + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// One adapter table owns worker -> state -> public error/HTTP mapping. +// HTTP applies to GET result; GET job/list always return a job view with 200. +// Reconcile means admission may have succeeded: do not release the lease until +// a snapshot or NOT_FOUND confirms the outcome. Never retain worker messages. +type workerFailure struct { + State string + Code string + HTTP int + Reconcile bool +} + +var workerFailures = map[string]workerFailure{ + "rpc:InvalidArgument": {"failed", "bad_request", 400, false}, + "rpc:FailedPrecondition": {"failed", "processing_unsupported", 422, false}, + "rpc:Unimplemented": {"failed", "processing_unsupported", 422, false}, + "rpc:NotFound": {"paused", "", 409, false}, + "rpc:AlreadyExists": {"processing", "", 409, true}, + "rpc:ResourceExhausted": {"paused", "", 409, false}, + "rpc:Unavailable": {"processing", "", 409, true}, + "rpc:DeadlineExceeded": {"processing", "", 409, true}, + "rpc:Canceled": {"processing", "", 409, true}, + "job:INPUT_NOT_FOUND": {"failed", "storage_failed", 503, false}, + "job:INPUT_UNREADABLE": {"failed", "invalid_media", 422, false}, + "job:INPUT_UNSUPPORTED": {"failed", "unsupported_media_type", 415, false}, + "job:INPUT_TOO_LARGE": {"failed", "upload_too_large", 413, false}, + "job:INPUT_TOO_LONG": {"failed", "duration_exceeded", 422, false}, + "job:PATH_OUTSIDE_ROOT": {"failed", "processing_failed", 500, false}, + "job:OUTPUT_EXISTS": {"failed", "processing_failed", 500, false}, + "job:CHECKPOINT_INVALID": {"failed", "processing_failed", 500, false}, + "job:CHECKPOINT_MISMATCH": {"failed", "processing_failed", 500, false}, + "job:EMBEDDING_MODEL_MISMATCH": {"failed", "processing_failed", 500, false}, + "job:WHITELIST_SESSION_NOT_FOUND": {"failed", "processing_failed", 500, false}, + "job:DECODER_FAILED": {"failed", "invalid_media", 422, false}, + "job:ENCODER_FAILED": {"failed", "processing_failed", 500, false}, + "job:MUX_FAILED": {"failed", "processing_failed", 500, false}, + "job:DISK_FULL": {"failed", "storage_failed", 503, false}, + "job:RUNTIME_FAILED": {"paused", "", 409, false}, + "job:CANCELLED": {"cancelled", "job_cancelled", 409, false}, + "job:INTERNAL": {"failed", "processing_failed", 500, false}, +} + +func mapWorkerFailure(key string) workerFailure { + if m, ok := workerFailures[key]; ok { + return m + } + return workerFailures["job:INTERNAL"] +} +func mapRPCFailure(err error) workerFailure { + key := "rpc:" + status.Code(err).String() + if m, ok := workerFailures[key]; ok { + return m + } + m := mapWorkerFailure("") + // An unknown transport failure is not proof that Start was rejected. + m.Reconcile = true + return m +} +func workerMissing(err error) bool { + return err != nil && status.Code(err) == codes.NotFound +} + +// Stored public codes are allowlisted too, including rows from older servers. +func publicFailure(code string) workerFailure { + for _, m := range workerFailures { + if m.Code != "" && m.Code == code { + return m + } + } + return mapWorkerFailure("") +} diff --git a/internal/vod/worker_errors_test.go b/internal/vod/worker_errors_test.go new file mode 100644 index 0000000..449f70b --- /dev/null +++ b/internal/vod/worker_errors_test.go @@ -0,0 +1,198 @@ +package vod + +import ( + "context" + "net" + "net/http/httptest" + "strings" + "testing" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + pb "inno-live-server/api/gen/aiv1" + "inno-live-server/internal/ai" +) + +// Explicit expectations cover every interface-A §6 row, plus transport errors +// and UNIMPLEMENTED from workers that do not yet expose the VOD service. +func TestWorkerFailureTable(t *testing.T) { + tests := []struct { + key, state, code string + http int + reconcile bool + }{ + {"rpc:InvalidArgument", "failed", "bad_request", 400, false}, + {"rpc:FailedPrecondition", "failed", "processing_unsupported", 422, false}, + {"rpc:Unimplemented", "failed", "processing_unsupported", 422, false}, + {"rpc:NotFound", "paused", "", 409, false}, + {"rpc:AlreadyExists", "processing", "", 409, true}, + {"rpc:ResourceExhausted", "paused", "", 409, false}, + {"rpc:Unavailable", "processing", "", 409, true}, + {"rpc:DeadlineExceeded", "processing", "", 409, true}, + {"rpc:Canceled", "processing", "", 409, true}, + {"job:INPUT_NOT_FOUND", "failed", "storage_failed", 503, false}, + {"job:INPUT_UNREADABLE", "failed", "invalid_media", 422, false}, + {"job:INPUT_UNSUPPORTED", "failed", "unsupported_media_type", 415, false}, + {"job:INPUT_TOO_LARGE", "failed", "upload_too_large", 413, false}, + {"job:INPUT_TOO_LONG", "failed", "duration_exceeded", 422, false}, + {"job:PATH_OUTSIDE_ROOT", "failed", "processing_failed", 500, false}, + {"job:OUTPUT_EXISTS", "failed", "processing_failed", 500, false}, + {"job:CHECKPOINT_INVALID", "failed", "processing_failed", 500, false}, + {"job:CHECKPOINT_MISMATCH", "failed", "processing_failed", 500, false}, + {"job:EMBEDDING_MODEL_MISMATCH", "failed", "processing_failed", 500, false}, + {"job:WHITELIST_SESSION_NOT_FOUND", "failed", "processing_failed", 500, false}, + {"job:DECODER_FAILED", "failed", "invalid_media", 422, false}, + {"job:ENCODER_FAILED", "failed", "processing_failed", 500, false}, + {"job:MUX_FAILED", "failed", "processing_failed", 500, false}, + {"job:DISK_FULL", "failed", "storage_failed", 503, false}, + {"job:RUNTIME_FAILED", "paused", "", 409, false}, + {"job:CANCELLED", "cancelled", "job_cancelled", 409, false}, + {"job:INTERNAL", "failed", "processing_failed", 500, false}, + } + if len(tests) != len(workerFailures) { + t.Fatal("mapping coverage changed") + } + for _, tc := range tests { + t.Run(tc.key, func(t *testing.T) { + got := mapWorkerFailure(tc.key) + if got != (workerFailure{tc.state, tc.code, tc.http, tc.reconcile}) { + t.Fatalf("mapping=%+v", got) + } + if strings.HasPrefix(tc.key, "rpc:") { + for code := codes.Canceled; code <= codes.Unauthenticated; code++ { + if "rpc:"+code.String() == tc.key && mapRPCFailure(status.Error(code, "private worker path")) != got { + t.Fatal("RPC adapter differs") + } + } + return + } + s := testService(t) + e := NewEngine(s, nil, nil, nil) + j := createJob(t, s, "processing") + j.WorkerTarget = "private-worker" + saveJob(t, s, &j) + v := &pb.VodJobStatus{JobId: j.ID.String(), State: pb.VodJobState_VOD_JOB_STATE_FAILED, ErrorCode: strings.TrimPrefix(tc.key, "job:"), ErrorMessage: "private worker path", CheckpointToken: "checkpoint"} + if err := e.apply(context.Background(), &j, v); err != nil { + t.Fatal(err) + } + persisted, err := s.store.Load().Get(context.Background(), j.ID, j.UserID) + if err != nil || persisted.Status != tc.state || persisted.FailureCode != tc.code { + t.Fatalf("job=%+v err=%v", persisted, err) + } + if tc.state == "paused" && (persisted.CompletedAt != nil || persisted.OriginalDeleteAt != nil || persisted.CheckpointToken != "checkpoint" || persisted.WorkerTarget != "private-worker") { + t.Fatal("retry lost checkpoint/target or started retention") + } + h := handler(s) + w := request(t, h, "GET", "/vod/jobs/"+j.ID.String(), j.UserID, nil, "") + if w.Code != 200 || strings.Contains(w.Body.String(), "private") || (tc.code != "" && !strings.Contains(w.Body.String(), `"code":"`+tc.code+`"`)) { + t.Fatal(w.Code, w.Body.String()) + } + w = request(t, h, "GET", "/vod/jobs/"+j.ID.String()+"/result", j.UserID, nil, "") + wantCode := tc.code + if wantCode == "" { + wantCode = "result_not_ready" + } + assertPublicError(t, w, tc.http, wantCode) + }) + } +} + +func assertPublicError(t *testing.T, w *httptest.ResponseRecorder, http int, code string) { + t.Helper() + if w.Code != http || !strings.Contains(w.Body.String(), `"code":"`+code+`"`) || strings.Contains(w.Body.String(), "private") { + t.Fatal(w.Code, w.Body.String()) + } +} + +func TestUnknownWorkerErrorsAreSanitized(t *testing.T) { + for _, key := range []string{"job:NEW_CODE/private", "job:", "rpc:Unknown", "rpc:Internal"} { + t.Run(key, func(t *testing.T) { + m := mapWorkerFailure(key) + if m.State != "failed" || m.Code != "processing_failed" || m.HTTP != 500 { + t.Fatal(m) + } + }) + } + if m := mapRPCFailure(status.Error(codes.Unknown, "private")); !m.Reconcile || m.Code != "processing_failed" { + t.Fatal(m) + } + s := testService(t) + j := createJob(t, s, "failed") + j.FailureCode = "private old unknown code" + saveJob(t, s, &j) + w := request(t, handler(s), "GET", "/vod/jobs/"+j.ID.String(), j.UserID, nil, "") + if w.Code != 200 || strings.Contains(w.Body.String(), "private") || !strings.Contains(w.Body.String(), "processing_failed") { + t.Fatal(w.Body.String()) + } + assertPublicError(t, request(t, handler(s), "GET", "/vod/jobs/"+j.ID.String()+"/result", j.UserID, nil, ""), 500, "processing_failed") +} + +type idleGRPCWorker struct{ GRPCWorker } + +func (idleGRPCWorker) LiveStreams(context.Context, string) (uint64, error) { return 0, nil } + +func TestGRPCWorkerFailureAPIAndCapacityRetry(t *testing.T) { + for _, tc := range []string{"INPUT_TOO_LARGE", "RESOURCE_EXHAUSTED"} { + t.Run(tc, func(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + server := grpc.NewServer() + fake := &rpcWorker{} + if tc == "INPUT_TOO_LARGE" { + fake.startFailure = tc + } else { + fake.startError = status.Error(codes.ResourceExhausted, "private worker capacity") + } + pb.RegisterVodProcessorServer(server, fake) + pb.RegisterAiProcessorServer(server, fake) + go func() { _ = server.Serve(listener) }() + defer server.Stop() + pool, err := ai.NewPool([]string{listener.Addr().String()}, time.Second) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + s := testService(t) + s.Config.WorkerGPUs = map[string]string{listener.Addr().String(): "GPU-0"} + e := NewEngine(s, pool, idleGRPCWorker{GRPCWorker{pool}}, fakeMemory{}) + ctx, cancel := context.WithCancel(context.Background()) + defer func() { cancel(); e.wg.Wait() }() + j := createJob(t, s, "queued") + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + e.wg.Wait() + current, err := s.store.Load().Get(ctx, j.ID, j.UserID) + if err != nil { + t.Fatal(err) + } + if tc == "INPUT_TOO_LARGE" { + if current.Status != "failed" || current.FailureCode != "upload_too_large" { + t.Fatal(current) + } + assertPublicError(t, request(t, handler(s), "GET", "/vod/jobs/"+j.ID.String()+"/result", j.UserID, nil, ""), 413, "upload_too_large") + detail := request(t, handler(s), "GET", "/vod/jobs/"+j.ID.String(), j.UserID, nil, "") + if detail.Code != 200 || !strings.Contains(detail.Body.String(), `"code":"upload_too_large"`) || strings.Contains(detail.Body.String(), "private") { + t.Fatal(detail.Body.String()) + } + } else { + if current.Status != "paused" || current.FailureCode != "" || current.CompletedAt != nil || !current.QueuedAt.Equal(j.QueuedAt.Truncate(time.Microsecond)) { + t.Fatal(current) + } + assertPublicError(t, request(t, handler(s), "GET", "/vod/jobs/"+j.ID.String()+"/result", j.UserID, nil, ""), 409, "result_not_ready") + fake.mu.Lock() + fake.startError = nil + fake.start = nil + fake.mu.Unlock() + if err := e.Tick(ctx); err != nil { + t.Fatal(err) + } + eventually(t, func() bool { fake.mu.Lock(); defer fake.mu.Unlock(); return fake.start != nil }) + } + }) + } +} diff --git a/internal/vod/worker_test.go b/internal/vod/worker_test.go index 61be940..d8f353e 100644 --- a/internal/vod/worker_test.go +++ b/internal/vod/worker_test.go @@ -16,15 +16,23 @@ import ( type rpcWorker struct { pb.UnimplementedVodProcessorServer pb.UnimplementedAiProcessorServer - mu sync.Mutex - start *pb.StartVodJobRequest - purged bool + mu sync.Mutex + start *pb.StartVodJobRequest + purged bool + startFailure string + startError error } func (f *rpcWorker) StartVodJob(_ context.Context, r *pb.StartVodJobRequest) (*pb.VodJobStatus, error) { f.mu.Lock() f.start = proto.Clone(r).(*pb.StartVodJobRequest) - f.mu.Unlock() + defer f.mu.Unlock() + if f.startError != nil { + return nil, f.startError + } + if f.startFailure != "" { + return &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_FAILED, ErrorCode: f.startFailure, ErrorMessage: "private worker path"}, nil + } return &pb.VodJobStatus{JobId: r.JobId, State: pb.VodJobState_VOD_JOB_STATE_PROCESSING, CheckpointToken: r.CheckpointToken}, nil } func (f *rpcWorker) WatchVodJob(r *pb.VodJobRef, stream grpc.ServerStreamingServer[pb.VodJobStatus]) error { From b7d5d1b94868a291e0d8e710eb0986e7f2449d4e Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 19:50:57 +0900 Subject: [PATCH 16/23] =?UTF-8?q?docs:=20VOD=20=ED=95=9C=EB=8F=84=EC=99=80?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EB=A7=A4=ED=95=91=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EA=B2=B0=EA=B3=BC=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/README.md | 63 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/api/vod/README.md b/api/vod/README.md index 01603be..bfa2768 100644 --- a/api/vod/README.md +++ b/api/vod/README.md @@ -174,3 +174,66 @@ go test -race -count=1 -json ./... - 파기: 삭제·취소·실패 및 계정 탈퇴(VOD 비활성 포함), 탈퇴 실패 롤백, 다른 사용자 보존, 늦은 작업 저장 충돌, 파일 파기 실패 시에도 이름 제거, 구조화/SQL/파기 감사 로그 미노출 확인 - 마이그레이션: 000019 up/down 반복 적용, 작업 행 보존, 기존 행 NULL, auto/versioned SQL 일치, auto 재적용 확인; 기존 마이그레이션 수정 없음 - `git diff --check` 통과; PR #519 변경 파일과 교집합 없음; 원격 쓰기 미수행 + +## innolive-ai #83 QA: 한도와 오류 매핑 검증 (2026-10-26) + +- 기준: `/workspace/innolive-vod/interface-A.md` §6의 RPC 오류 6종·잡 오류 18종, 추가 RPC `UNIMPLEMENTED` 및 전송 취소·기한 오류 +- 저장소의 운영 기본값은 config·`.env.example`·문서·테스트 모두 3600초/2147483648 B; 운영 호스트의 실제 환경 변수는 접속 금지 조건에 따라 미확인 +- VOD 활성화 시 워커 한도 초과 설정은 서버 시작 오류; 더 작은 양의 상한 허용, 비활성 시 기존 설정 무시 정책 유지 +- 매핑 원본: `internal/vod/worker_errors.go`의 단일 테이블; 내부 경로·워커 주소·원문 오류 메시지는 저장된 공개 코드·API 응답에 미포함 +- OpenAPI 0.3.0: `VodFailureCode` 9종과 TypeScript 예시 일치, 실패/취소 결과 요청의 구체적 HTTP 오류 추가 +- 비동기 계약: 업로드 선검증 통과는 201, 이후 목록/상세는 200과 `status`·`failure.code` 제공; 아래 HTTP는 결과 다운로드 요청 기준 + +| 워커 입력 | 작업 상태 | 결과 요청 HTTP / error.code | +| --- | --- | --- | +| RPC `INVALID_ARGUMENT` | failed | 400 / bad_request | +| RPC `FAILED_PRECONDITION`, `UNIMPLEMENTED` | failed | 422 / processing_unsupported | +| RPC `RESOURCE_EXHAUSTED` | paused, 기존 순번으로 재시도 | 409 / result_not_ready | +| RPC `NOT_FOUND` | paused, 체크포인트 재개 | 409 / result_not_ready | +| RPC `ALREADY_EXISTS` | processing, Get/Pause로 현재 상태 확인 | 409 / result_not_ready | +| RPC `UNAVAILABLE`, `DEADLINE_EXCEEDED`, `CANCELLED` | processing에서 확인, NOT_FOUND이면 paused | 409 / result_not_ready | +| 알 수 없는 RPC (`UNKNOWN`, `INTERNAL` 등) | 상태 확인 후 NOT_FOUND이면 failed | 확인 중 409 / result_not_ready, 실패 확정 후 500 / processing_failed | +| 잡 `INPUT_TOO_LARGE` | failed | 413 / upload_too_large | +| 잡 `INPUT_TOO_LONG` | failed | 422 / duration_exceeded | +| 잡 `INPUT_UNREADABLE`, `DECODER_FAILED` | failed | 422 / invalid_media | +| 잡 `INPUT_UNSUPPORTED` | failed | 415 / unsupported_media_type | +| 잡 `INPUT_NOT_FOUND`, `DISK_FULL` | failed | 503 / storage_failed | +| 잡 `PATH_OUTSIDE_ROOT`, `OUTPUT_EXISTS`, `CHECKPOINT_INVALID`, `CHECKPOINT_MISMATCH`, `EMBEDDING_MODEL_MISMATCH`, `WHITELIST_SESSION_NOT_FOUND`, `ENCODER_FAILED`, `MUX_FAILED`, `INTERNAL`, 알 수 없는 잡 코드 | failed | 500 / processing_failed | +| 잡 `RUNTIME_FAILED` | paused, 다른 워커에서 재개 | 409 / result_not_ready | +| 잡 `CANCELLED` 또는 CANCELLED 상태 | cancelled | 409 / job_cancelled | + +- `RESOURCE_EXHAUSTED`는 사용자 입력 오류로 변환하지 않으며 `failure=null`, 큐 진입 시각·체크포인트·원본 보존 유지 +- 전송 오류는 Start 성공 응답 유실 가능성 때문에 확인 전에 lease 해제·중복 실행·원본 파기 금지; 정상 snapshot을 받으면 그 상태 우선 +- `INPUT_NOT_FOUND`는 서버 공유 저장소 오류이므로 사용자 작업 없음(404)과 구분; 경로·체크포인트·임베딩 불일치는 일반 처리 오류로 은닉 +- `RUNTIME_FAILED`는 DB의 paused 작업에 직전 실패 타깃을 보존하여 다음 배정에서 제외; 서버 재생성 후에도 적용, 다른 대기 작업 진행 허용, 이전 워커의 오래된 실패 snapshot 무시 +- 503/storage_failed도 작업 자체는 terminal이며 자동 재시도 없음; 클라이언트는 상세의 failed 상태와 함께 판정 + +검증 환경은 Go 1.27.3, 로컬 PostgreSQL 17.11, UTF-8 `vod_test`, 포트 55433, ffmpeg/ffprobe 및 가짜 워커이다. 기존 `/tmp/innolive-vod-pg` 클러스터를 재사용했다. + +```bash +/usr/lib/postgresql/17/bin/pg_ctl -D /tmp/innolive-vod-pg \ + -l /tmp/innolive-vod-qa-pg.log -o '-h 127.0.0.1 -p 55433 -k /tmp' start +export TEST_DATABASE_URL='postgres://box@127.0.0.1:55433/vod_test?sslmode=disable' +go build ./... +go vet ./... +go test -race -count=1 -json ./... > /tmp/innolive-vod-qa-race.jsonl +``` + +| 검증 | 결과 | +| --- | --- | +| `go build ./...` | 통과 | +| `go vet ./...` | 통과 | +| `go test -race -count=1 -json ./...` | 1,405 통과 / 0 실패 / 1 건너뜀; race 0건; 테스트 패키지 15개 통과 | +| 패키지별 pass 이벤트 | VOD 135개, AI 79개, config 66개 | +| 오류 테이블 | RPC 9개·잡 18개 전 항목 기대값 검증; 잡 상태 저장·상세·결과 HTTP 검증 | +| 가짜 gRPC 워커 | INPUT_TOO_LARGE → 상세 200/failure.code·결과 413, RESOURCE_EXHAUSTED → 큐 유지 후 재호출 확인 | +| 한도 경계 | 설정 2 GiB·3600초 허용, +1 거부; 스트림 2 GiB/+1 B/긴 잔여 본문, HTTP 32/33 B·3600/3601초·3600.1초 확인 | +| OpenAPI | YAML 파싱, 매핑 27개 HTTP 상태 및 공개 코드 9종·TypeScript 일치 확인 | + +- 테스트 수는 하위 테스트 포함 Go JSON pass/skip 이벤트 기준; 테스트 없는 5개 패키지는 건너뜀 수에서 제외 +- 유일한 건너뜀은 기존 `TestLocalDeviceMediaProbe`의 장치 probe opt-in 미설정 +- 바이트 2 GiB 경계는 메모리 고정 크기의 생성 reader/counting writer로 실제 해당 바이트 수를 처리하며 디스크 2 GiB 파일은 미생성; HTTP·DB·정리 검증은 32바이트 상한 사용 +- 길이 경계는 fake probe로 정확한 시간 값을 주입; 실제 ffmpeg/ffprobe는 기존 1초 H.264 허용·MPEG-4 거부 테스트 통과 +- 실제 AI 워커·NVIDIA GPU E2E, 운영 공유 경로·권한·배포는 미검증; 사용 가능한 다른 워커가 없거나 이전 실행 확인이 불가능하면 재시도 대기 지속 +- 체크포인트가 없는 실패는 기존 신규 시작 경로 사용; 유효한 토큰이 있는 경우만 Resume하며 워커의 체크포인트 검증 오류를 일반 실패로 보존 +- `git diff --check` 통과, PR #519 변경 경로와 교집합 없음; 원격 쓰기·운영 서버 접속·innolive-ai 수정 미수행 From 721b4b74b38bd4fb6f2816b11a2ca9ac0b76791c Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 20:57:02 +0900 Subject: [PATCH 17/23] =?UTF-8?q?docs:=20VOD=20=EC=9A=B4=EC=98=81=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EB=9F=B0=EB=B6=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/DEPLOY.md | 252 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 252 insertions(+) create mode 100644 api/vod/DEPLOY.md diff --git a/api/vod/DEPLOY.md b/api/vod/DEPLOY.md new file mode 100644 index 0000000..13d7bbe --- /dev/null +++ b/api/vod/DEPLOY.md @@ -0,0 +1,252 @@ +# VOD 운영 배포 런북 + +기준: `feat/vod-jobs/#521`, PR #522, 서버 소스 `b7d5d1b`. 아래 `파일:라인`은 이 소스 기준이며, 절대 경로는 별도 워커 인터페이스 문서다. 명령은 운영 담당자가 실행할 절차 예시이고 이 문서 작성 과정에서 실행한 배포 기록이 아니다. 코드 기본값과 운영 실효값을 구분하며, 확인하지 못한 값은 **운영에서 확인 필요**로 표시한다. + +## 1. 운영 env와 저장소 준비 + +서버 env는 `/etc/innolive/server.env`, `server-secrets.env`, `server-slot-${INNOLIVE_SLOT}.env` 순서로 주입되며 뒤의 값이 앞의 값을 덮는다. 비밀 파일 전체를 출력하지 않고 실제 컨테이너의 아래 허용된 키만 확인한다. `docker compose config`는 시크릿을 병합 출력하므로 사용하지 않는다. 근거: `deploy/compose.prod.yaml:10-11,27-31`. + +| 항목 | 활성화 시 기준값 / 설정 방법 | 근거와 운영 확인 | +| --- | --- | --- | +| `VOD_ENABLED` | 최종 활성화 `true`, 최초 배포와 긴급 차단은 `false` | 기본 비활성, false면 나머지 VOD env 파싱도 생략: `internal/config/vod.go:34-50`. 라우트와 백그라운드 비등록: `internal/server/vod.go:22-32` | +| 워커 gRPC 주소 | 기존 `AI_GRPC_TARGETS`의 쉼표 구분 주소 목록 재사용. 비어 있으면 `AI_GRPC_ADDR` 하나 사용 | `internal/config/config.go:183-184,269-273,608-615`, `internal/ai/vod_client.go:5-6`. `AI_GRPC_ADDR` 코드 기본값은 `localhost:50051`이나 실제 운영 주소·포트는 **운영에서 확인 필요**. 별도 VOD 주소 env는 이 경로에 없음 | +| AI 실행 모드 | `AI_PRIVACY_MODE=real`, 현재 연결 방식은 `AI_GRPC_INSECURE=true` | real일 때만 AI pool 생성, TLS 설정 false는 시작 오류: `cmd/server/main.go:138-150`. 실제 운영 설정은 **운영에서 확인 필요** | +| `VOD_WORKER_GPUS` | `<실제 target>=<실제 GPU UUID>,<다른 target>=<실제 GPU UUID>` | 쉼표 구분 `target=GPU-UUID`: `internal/config/vod.go:95-100`, `internal/config/config.go:608-615`. 같은 물리 GPU의 모든 슬롯은 같은 UUID, pool의 모든 target과 교체 슬롯까지 매핑 필요. 누락 시 VOD 배정 중단: `internal/ai/vod.go:28-55`, `internal/vod/scheduler.go:525-535`. 실제 매핑은 **운영에서 확인 필요** | +| `VOD_ROOT` | `/var/lib/innolive/vod` | 서버 기본값: `internal/config/vod.go:35`. 서버와 AI 워커가 같은 호스트의 같은 절대 경로를 공유하고 워커 `--vod-root`/`VOD_ROOT`도 일치해야 함: `/workspace/innolive-vod/interface-A.md:285-297`. 서버 마운트: `deploy/compose.prod.yaml:32-34` | +| 소유자·권한 | 서버 UID `65532`. 서버는 생성·읽기·삭제, 워커는 입력 읽기와 작업/출력 생성·삭제 권한 필요 | `Dockerfile:13-17`, `internal/vod/http.go:135-149`, `internal/vod/sweeper.go:94-119`, `/workspace/innolive-vod/interface-A.md:287-297`. 실제 워커 UID/GID, 서버 GID, ACL, umask는 **운영에서 확인 필요** | +| `VOD_MAX_DURATION_SEC` | `3600`초 | `internal/config/vod.go:12-16,68-75,109-120`. 더 작은 양수 허용, 3600 초과는 VOD 활성화 시 서버 시작 오류 | +| `VOD_MAX_UPLOAD_BYTES` | `2147483648` B = 2,147,483,648 B = 2 GiB | `internal/config/vod.go:12-16,56-63,109-120`. 더 작은 양수 허용, 워커 고정 상한 초과는 서버 시작 오류. 설정 오류 전파: `internal/config/config.go:252-255`, `cmd/server/main.go:34-37` | +| `VOD_RESULT_RETENTION_SEC` | `604800`초 = 7일 | `internal/config/vod.go:35-37,68-75`, `internal/vod/service.go:133-138`. 성공 완료 시 결과 만료 시각 확정 | +| `VOD_ORIGINAL_RETENTION_SEC` | `86400`초 = 24시간, 허용 범위 0..86400 | `internal/config/vod.go:36,68-75,119`, `internal/vod/service.go:121-138`. 종료 후 파기 상한이며 보장 보관 시간이 아님. 종료 확인 후 원본·work·partial을 먼저 정리: `internal/vod/sweeper.go:14-16,43-69` | +| `VOD_SWEEP_INTERVAL` | `1m` | 기본 1분, Go duration 형식, `0 < 값 <= 1h`: `internal/config/vod.go:37,77-87,119`. 다음 기한이 더 가까우면 대기를 단축하고 삭제 시 깨움: `internal/vod/sweeper.go:124-158`, `internal/vod/http.go:332-335` | +| `VOD_MIN_FREE_MIB` | `4096` MiB | GPU 여유 메모리 기준이며 디스크 여유 공간 기준이 아님: `internal/config/vod.go:36,56`, `internal/vod/worker.go:120-137`, `internal/ai/vod.go:43-46`. 실제 GPU 여유량·조정값은 **운영에서 확인 필요** | +| `VOD_MAX_CONCURRENCY` | `1` | 양의 정수, 전체 VOD lease 수 제한과 GPU당 1개 제한: `internal/config/vod.go:36,89-93,119`, `internal/ai/vod.go:36-45`. 증설 시 실제 용량은 **운영에서 확인 필요** | +| `VOD_UPLOAD_TIMEOUT` | `30m` | Go duration 형식, 양수: `internal/config/vod.go:37,77-87,119`. 요청 context와 업로드 read deadline에 적용: `internal/vod/http.go:90-94` | +| 리버스 프록시 업로드 제한 | 전체 multipart를 최소 `2148532224` B까지 전달할 수 있는지 확인 | 서버 본문 상한은 파일 상한 + `1048576` B: `internal/vod/http.go:95-103`. 실제 프록시/CDN의 본문 제한·읽기/응답 timeout·라우팅은 **운영에서 확인 필요**. Caddy 설정 경로는 레포 밖 운영 env에서 읽음: `deploy/apply-release.sh:26-30` | +| `DATABASE_MIGRATION_MODE` | 현재 운영 모드를 유지하며 아래 절차로 확인 | 코드 기본 `auto`: `internal/config/config.go:247-249`. 실제 값은 **운영에서 확인 필요**. 버전 19 확인을 위해 임의로 `auto`를 `versioned`로 바꾸지 않음 | + +공유 디렉터리는 서버가 작업별 디렉터리를 `0750`, 입력 파일을 `0640`으로 생성한다. 따라서 루트 디렉터리의 그룹 쓰기 권한만 추가해도 워커가 새 작업 디렉터리에 쓸 수 있는 것은 아니다. 동일 UID 실행 또는 생성 후 유효 권한까지 보장하는 운영 권한 구성이 필요하다. 서버가 워커 생성 결과를 읽고 work 하위 파일을 삭제할 수 있는지도 확인한다. 특정 워커 계정이나 GID, 일괄 `chmod` 값을 가정하지 않는다. 근거: `internal/vod/http.go:135-149`, `internal/vod/http.go:371-384`, `internal/vod/sweeper.go:100-106`, `/workspace/innolive-vod/interface-A.md:297`. + +워커 한도 env 이름은 서버와 다르다. 인터페이스의 워커 기본값은 `VOD_MAX_INPUT_BYTES=2147483648`, `VOD_MAX_DURATION_SECONDS=3600`, `VOD_MAX_JOBS=1`이며 마지막 값이 0이면 RPC가 비활성이다. 실제 워커 설정이 서버와 일치하는지는 **운영에서 확인 필요**. 근거: `/workspace/innolive-vod/interface-A.md:372-381`. + +## 2. 배포 경로와 마이그레이션 + +main push의 변경 경로가 `api/**`, `cmd/**`, `internal/**`, `go.mod`, `go.sum`, `Dockerfile`, `.dockerignore` 중 하나이면 Deploy workflow 대상이다. 따라서 PR #522의 코드뿐 아니라 **이 `api/vod/DEPLOY.md`만 main에 반영해도 배포 트리거에 해당한다**. 수동 `workflow_dispatch`도 있다. 근거: `.github/workflows/deploy.yml:3-19`. + +워크플로는 SHA/latest 이미지 빌드·push 후 SSH 키를 준비하고 `deploy@<운영 호스트>`에 `deploy `를 전달한다. 호스트·포트는 시크릿 참조이며 값은 **운영에서 확인 필요**. forced command는 `apply-release.sh`로 연결되고 이미지 pull, 로그 보관, 새 슬롯 기동·health 확인, 전환 후 옛 슬롯 정지를 수행한다. 근거: `.github/workflows/deploy.yml:44-77`, `deploy/receive-deploy.sh:10-28`, `deploy/apply-release.sh:115-148,178-195`. + +**`VOD_ENABLED=false`는 재배포나 마이그레이션을 막지 않는다.** main 반영 시 워크플로의 배포 경로를 타며, 새 서버는 설정 로드와 DB 연결이 성공하면 VOD 플래그 조건 없이 `migration.Run`을 호출한다. workflow의 실제 실행 성공·환경 승인/보호 설정은 **운영에서 확인 필요**. 마이그레이션 실패 시 서버는 종료한다. 근거: `cmd/server/main.go:34-37,49-85`, `.github/workflows/deploy.yml:28-34,64-77`. + +| 모드 | 시작 시 동작 | 근거 | +| --- | --- | --- | +| `versioned` | 내장 SQL에 `Up()` 적용. 이전 버전 17이면 000018, 000019를 차례로 자동 적용. 이미 최신이면 변경 없음 | `internal/database/migration/migration.go:48-49`, `internal/database/migration/versioned.go:13-31,57-63` | +| `auto` (기본) | `vod.AutoMigrate`가 `schema.sql`과 `filename.sql`을 트랜잭션에서 실행해 VOD 테이블·원본 파일명 컬럼 생성. 번호 기반 18/19 이력 적용과는 구분 | `internal/database/migration/migration.go:29-46`, `internal/vod/store.go:15-27`, `internal/vod/schema.sql:1-34`, `internal/vod/filename.sql:1` | +| `off` | `migration.Run`은 호출되지만 스키마 변경 생략. 별도 적용·검증 필요 | `internal/database/migration/migration.go:51-52` | + +000018은 `vod_jobs`, 관련 인덱스, `vod_deletion_logs`를 만들고, 000019는 `vod_jobs.original_filename`을 추가한다. 근거: `internal/database/migration/sql/000018_create_vod_jobs.up.sql:1-34`, `internal/database/migration/sql/000019_add_vod_original_filename.up.sql:1`. + +## 3. 기능 켜는 순서 + +아래 명령의 `VOD_SLOT`, `VOD_BASE_URL`, `VOD_WORKER_TARGET`, `VOD_WORKER_UNIT`, `VOD_PG_SERVICE`, `VOD_AUTH_CONFIG`, `VOD_SAMPLE`, `VOD_SMOKE_DIR`, `VOD_JOB_ID`는 운영 담당자가 확인한 값으로 설정한다. 모두 **운영에서 확인 필요**이며 임의의 주소·계정·토큰을 예시에 넣지 않는다. DB는 인증 정보를 별도 보관한 libpq service, HTTP는 권한 0600인 curl 설정 파일의 Authorization 헤더를 이용하는 절차 예시다. 도구 설치 여부와 이 인증 파일의 준비 방식도 **운영에서 확인 필요**. `set -x`, env 전체 출력, curl verbose/trace를 사용하지 않는다. + +### (a) VOD_ENABLED=false로 머지·배포 후 스키마 확인 + +운영 담당자가 먼저 실효 `VOD_ENABLED=false`를 확인한 뒤 승인된 배포 절차로 main에 반영한다. env 파일 수정만으로 실행 중 프로세스 설정은 바뀌지 않으며 새 슬롯/프로세스에 반영되어야 한다. 근거: `internal/config/config.go:179-180,252-256`, `internal/config/vod.go:43-50`, `deploy/innolive-server@.service:25-31`. + +읽기 확인 예시: + +```bash +systemctl is-active innolive-server@blue.service innolive-server@green.service +docker exec "innolive-server-${VOD_SLOT}" printenv VOD_ENABLED DATABASE_MIGRATION_MODE +curl --silent --show-error --fail "${VOD_BASE_URL}/health" --output /dev/null +psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 -c "SELECT to_regclass('public.vod_jobs'), to_regclass('public.vod_deletion_logs');" +psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 -c "SELECT column_name FROM information_schema.columns WHERE table_schema='public' AND table_name='vod_jobs' AND column_name='original_filename';" +``` + +env 미설정 시 `printenv`가 값을 출력하지 않을 수 있으므로 표의 코드 기본값과 구분한다. `public`은 확인 쿼리 예시이며 실제 DB schema/search_path는 **운영에서 확인 필요**. `versioned`에서는 승인된 migrate 도구의 `version`으로 19 및 clean 상태를 확인한다(4절). `auto`에서는 위 객체 확인을 사용한다. health 성공만으로 VOD 스키마를 확인했다고 판단하지 않는다. 슬롯·health 근거: `deploy/apply-release.sh:61-62,99-108`; 스키마 근거: `internal/database/migration/sql/000018_create_vod_jobs.up.sql:1-34`, `internal/database/migration/sql/000019_add_vod_original_filename.up.sql:1`. + +### (b) 공유 경로와 소유자·권한 준비 + +운영 담당자가 1절 권한 조건에 맞춰 경로를 준비한다. 호스트와 서버 컨테이너, 워커의 mount namespace에서 동일 저장소를 보는지 확인하고, 실제 샘플 작업 생성 후에도 0750/0640 권한을 다시 확인한다. 근거: `deploy/compose.prod.yaml:32-34`, `internal/vod/http.go:135-149`, `/workspace/innolive-vod/interface-A.md:285-297`. + +```bash +stat -c '%u:%g %a %n' /var/lib/innolive /var/lib/innolive/vod +namei -l /var/lib/innolive/vod +findmnt -T /var/lib/innolive/vod +df -h /var/lib/innolive/vod +systemctl show "${VOD_WORKER_UNIT}" --property=User --property=Group --property=UMask +docker exec "innolive-server-${VOD_SLOT}" id +docker exec "innolive-server-${VOD_SLOT}" stat -c '%u:%g %a %n' /var/lib/innolive/vod +docker exec "innolive-server-${VOD_SLOT}" sh -c 'test -r /var/lib/innolive/vod && test -w /var/lib/innolive/vod && test -x /var/lib/innolive/vod' +``` + +위 루트 디렉터리 확인만으로 작업 하위 디렉터리에 대한 워커 쓰기 권한까지 증명하지는 못한다. 실제 워커 사용자로 동일 경로를 읽고 쓸 수 있는지 스모크 작업의 권한과 함께 확인한다. + +### (c) AI 워커의 VodProcessor 지원 확인 + +선행 작업 `innolive-ai#83`이 포함된 버전이 **실제 실행 중인지 운영에서 확인 필요**. PR 번호는 작업 요청에서 지정한 의존성이며, 서버 레포만으로 해당 PR의 머지·배포 상태나 실행 SHA를 확인할 수 없다. 요구 서비스는 기존 워커와 같은 gRPC 포트의 `VodProcessor`다. 근거: `/workspace/innolive-vod/interface-A.md:3-5`, `internal/ai/vod_client.go:5-6`, `api/proto/ai_processor.proto:245-261`. + +서버 레포 루트에서 아래 읽기 RPC를 각 실제 target에 호출한다. proto 파일을 지정하므로 reflection 지원을 가정하지 않는다. `grpcurl` 설치·proto import 경로는 **운영에서 확인 필요**. 결과에는 작업 식별자가 포함될 수 있으므로 운영 내부에서 확인한다. + +```bash +grpcurl -plaintext -import-path api/proto -proto ai_processor.proto \ + -d '{}' "${VOD_WORKER_TARGET}" VodProcessor/ListVodJobs +docker exec "innolive-server-${VOD_SLOT}" nvidia-smi --query-gpu=uuid,memory.free --format=csv,noheader,nounits +``` + +`UNIMPLEMENTED` 또는 비활성/준비되지 않은 응답이면 활성화하지 않는다. List 성공만으로 처리·권한 검증을 대신하지 않고 (e)의 실제 처리까지 확인한다. GPU UUID는 모든 target의 실제 GPU 배치와 대조한다. 근거: `api/proto/ai_processor.proto:260-261`, `/workspace/innolive-vod/interface-A.md:377`, `internal/vod/worker.go:120-137`, `internal/ai/vod.go:28-55`. + +### (d) env 설정 후 재시작 + +운영 담당자가 1절 값을 설정하고 기존 슬롯 운영 절차로 서버를 재기동한다. 단일 슬롯 재시작도 라이브에 영향을 줄 수 있으므로 현재 활성 슬롯과 세션 상태를 확인한다. 여기서는 재시작을 실행하지 않는다. env 반영은 compose가 컨테이너를 기동하는 시점이며 적용 후 (a)의 health·스키마 확인과 아래 실효값 확인을 반복한다. 근거: `deploy/innolive-server@.service:25-31`, `deploy/compose.prod.yaml:27-31`, `deploy/apply-release.sh:124-148,183-193`. + +```bash +docker exec "innolive-server-${VOD_SLOT}" printenv \ + VOD_ENABLED AI_PRIVACY_MODE AI_GRPC_INSECURE AI_GRPC_ADDR AI_GRPC_TARGETS \ + VOD_WORKER_GPUS VOD_ROOT VOD_MAX_UPLOAD_BYTES VOD_MAX_DURATION_SEC \ + VOD_RESULT_RETENTION_SEC VOD_ORIGINAL_RETENTION_SEC VOD_SWEEP_INTERVAL \ + VOD_MIN_FREE_MIB VOD_MAX_CONCURRENCY VOD_UPLOAD_TIMEOUT +``` + +### (e) 업로드·상태·다운로드·삭제와 라이브 우선 스모크 + +승인된 테스트 계정과 폐기 가능한 짧은 MP4/H.264, AAC 또는 무음 샘플을 사용한다. 업로드와 DELETE만 검증에 필요한 쓰기이며, 나머지는 조회한다. 공개 프록시 경로와 실제 서버 경로를 구분해 검증한다. 입력 검증 근거: `internal/vod/service.go:32-81`; 인증·경로 근거: `internal/server/vod.go:26-28`, `internal/vod/http.go:26-36,51-83`. + +```bash +# 쓰기: 테스트 작업 업로드. 응답은 보호된 테스트 디렉터리에 저장. +curl --silent --show-error --config "${VOD_AUTH_CONFIG}" \ + --form "file=@${VOD_SAMPLE};type=video/mp4" \ + --output "${VOD_SMOKE_DIR}/upload.json" --write-out '%{http_code}\n' \ + "${VOD_BASE_URL}/vod/jobs" + +# upload.json의 job_id를 VOD_JOB_ID에 지정한 뒤 약 5초 간격으로 확인. +curl --silent --show-error --config "${VOD_AUTH_CONFIG}" \ + "${VOD_BASE_URL}/vod/jobs/${VOD_JOB_ID}" \ + | jq '{job_id,status,progress,original_delete_at,result_expires_at,failure}' + +# done 확인 후 다운로드. +curl --silent --show-error --fail --config "${VOD_AUTH_CONFIG}" \ + --output "${VOD_SMOKE_DIR}/result.mp4" --write-out '%{http_code}\n' \ + "${VOD_BASE_URL}/vod/jobs/${VOD_JOB_ID}/result" +ffprobe -v error -show_entries stream=codec_type,codec_name -of json "${VOD_SMOKE_DIR}/result.mp4" + +# 쓰기: 이 스모크 작업만 삭제. +curl --silent --show-error --config "${VOD_AUTH_CONFIG}" --request DELETE \ + --output /dev/null --write-out '%{http_code}\n' "${VOD_BASE_URL}/vod/jobs/${VOD_JOB_ID}" +curl --silent --show-error --config "${VOD_AUTH_CONFIG}" \ + --output /dev/null --write-out '%{http_code}\n' "${VOD_BASE_URL}/vod/jobs/${VOD_JOB_ID}" +curl --silent --show-error --config "${VOD_AUTH_CONFIG}" \ + --output /dev/null --write-out '%{http_code}\n' "${VOD_BASE_URL}/vod/jobs/${VOD_JOB_ID}/result" +``` + +기대값은 업로드 201, 상태 조회 200, 완료 후 다운로드 200, 삭제 204, 삭제 후 상세·결과 404다. DELETE 204는 물리 파기 완료가 아니므로 (f)를 확인한다. 원본 파기 시각과 결과 만료 시각도 확인한다. 근거: `internal/vod/http.go:218-224,226-239,319-348,350-422`, `internal/vod/store.go:84-86`, `internal/vod/service.go:121-138`; 폴링 간격 계약: `api/vod/openapi.yaml:99-103`. + +라이브 우선은 삭제 전 처리 중인 테스트 작업으로 별도 확인한다. 같은 GPU에 승인된 테스트 라이브 수요를 발생시키고 VOD가 paused/대기로 양보하며 라이브가 정상 지속되는지 관찰한다. 라이브 종료 후 자원이 비면 재개·완료되는지도 위 GET과 워커 List RPC로 확인한다. 라이브 생성 방법·허용 지연·부하 기준은 **운영에서 확인 필요**. 근거: `internal/ai/pool.go:233,280`, `internal/ai/vod.go:43-55,73-82`, `internal/vod/scheduler.go:525-559`, `/workspace/innolive-vod/interface-A.md:239-244`. + +### (f) 정리 작업과 파기 로그 확인 + +스위퍼는 DB advisory lock을 획득한 리더에서 실행한다. 원본·work·partial은 종료 확인 후 정리하며, 결과는 만료 또는 삭제·실패·취소 때 정리한다. 워커 종료 미확인도 파기 기한을 연장하지 않고 오류 로그를 남긴다. 근거: `internal/vod/scheduler.go:34-78`, `internal/vod/sweeper.go:43-85`. + +```bash +psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 -v vod_job_id="${VOD_JOB_ID}" <<'SQL' +SELECT status, completed_at, original_delete_at, result_expires_at, deleted_at +FROM vod_jobs WHERE id = :'vod_job_id'::uuid; +SELECT job_id, file_kind, path_hash, deleted_at, reason +FROM vod_deletion_logs WHERE job_id = :'vod_job_id'::uuid ORDER BY deleted_at; +SQL +find "/var/lib/innolive/vod/${VOD_JOB_ID}" -maxdepth 2 -type f -printf '%P\n' +journalctl -u 'innolive-server@*' --since '30 minutes ago' --no-pager \ + | rg -o 'vod_file_destroyed|vod_retention_worker_unconfirmed|vod retention retry required|vod reconciliation pending|vod scheduling deferred' +``` + +`VOD_JOB_ID`는 스모크 응답에서 받은 UUID인지 검증한 뒤 사용한다. + +DB의 `file_kind`, 상대 경로 SHA-256인 `path_hash`, `deleted_at`, `reason`과 실제 파일 부재를 대조한다. 위 로그 명령은 비밀 정보 노출을 피하기 위해 이벤트 이름만 출력하며, 스모크 ID별 증적은 DB 조회를 사용한다. 빈 작업 디렉터리가 남는 것과 파일 파기는 구분한다. 근거: `internal/vod/service.go:108-119`, `internal/vod/sweeper.go:94-120`, `internal/vod/store.go:110-111`. + +원본은 24시간을 기다리지 않고 먼저 삭제될 수 있다. 실제 7일 결과 만료와 기한 직전 다운로드 차단은 아직 도래하지 않은 시점에 검증 완료로 표시하지 않는다. 정리 실패·워커 미확인·비활성 기간의 수동 파기 담당자와 대응시간, 로그 보관 기간은 **운영에서 확인 필요**. 근거: `internal/vod/sweeper.go:14-16,46-53,124-158`, `internal/vod/http.go:355-357,393-426`. + +### (g) 문제 시 즉시 끄기 + +운영 담당자가 `VOD_ENABLED=false`로 바꾸고 실행 중인 모든 서버 슬롯에 재기동으로 반영한다. env 파일만 변경해서는 즉시 꺼지지 않는다. (a)의 실효값 확인과 `/vod/jobs` 요청의 미등록 응답을 확인하고, 각 워커 List RPC로 남은 처리 작업을 점검한다. 종료 경로는 VOD context를 취소하고 완료를 기다리지만, 워커 통신 장애 시 종료를 단정하지 않는다. 근거: `internal/server/vod.go:22-32,60-77`, `internal/vod/scheduler.go:340-358`. + +```bash +# 운영 담당자가 비활성 env를 반영해 재기동한 뒤, 살아 있는 각 슬롯에서 확인. +docker exec "innolive-server-${VOD_SLOT}" printenv VOD_ENABLED +curl --silent --show-error --config "${VOD_AUTH_CONFIG}" \ + --output /dev/null --write-out '%{http_code}\n' "${VOD_BASE_URL}/vod/jobs" +grpcurl -plaintext -import-path api/proto -proto ai_processor.proto \ + -d '{}' "${VOD_WORKER_TARGET}" VodProcessor/ListVodJobs +``` + +**false면 스위퍼도 멈춘다.** DB·파일·기한·감사 기록은 남으므로 기한 내 정리 책임을 운영 담당자에게 넘기고 필요하면 아래 롤백을 수행한다. 플래그를 껐다고 파일이나 스키마가 삭제되는 것은 아니다. 근거: `internal/vod/sweeper.go:17-20,124-127`, `internal/database/migration/migration.go:29-52`. + +## 4. 필요 시 롤백 + +아래는 승인된 장애 대응 시 운영 담당자가 수행할 **변경 명령 형태**다. 문서 작성 중 실행하지 않는다. 먼저 기능 비활성화와 바이너리 복귀만으로 충분한지 판단한다. 스키마 down은 VOD 작업·감사 테이블을 잃는 별도 단계다. 000018은 기존 테이블을 바꾸지 않으므로 무조건 down부터 수행하지 않는다. 근거: `internal/database/migration/sql/000018_create_vod_jobs.down.sql:1-5`, `deploy/README.md:64,74-76`. + +1. `VOD_ENABLED=false`를 모든 슬롯 env에 준비하고 서버를 중지한다. blue/green 외 실행 인스턴스도 없는지 확인한다. 워커에는 VOD Pause/Cancel 또는 승인된 워커 중지 절차를 적용하고, 파일을 다시 만들 프로세스가 없음을 확인한다. 워커 중지는 라이브도 영향을 받으므로 대상 유닛과 순서는 **운영에서 확인 필요**. 근거: `deploy/innolive-server@.service:30-33`, `internal/server/vod.go:60-77`, `api/proto/ai_processor.proto:256-261`, `internal/database/migration/sql/000018_create_vod_jobs.down.sql:1-2`. + + ```bash + # 변경: 스키마 롤백 전에 모든 서버 슬롯 중지. + sudo systemctl stop innolive-server@blue.service innolive-server@green.service + systemctl is-active innolive-server@blue.service innolive-server@green.service + ``` + +2. down 전에 VOD 경로의 입력·출력·work·partial을 운영 파기 절차로 정리하고 파기 증적을 확보한다. 자동 스위퍼가 이미 중지된 경우 수동 파기 증적에도 작업 ID, 파일 종류, 상대 경로 해시, 시각, 사유를 남긴다. 이미 있던 `vod_deletion_logs`와 추가 수동 증적을 접근 제한된 저장소에 반출·검증한 뒤에만 000018 down으로 진행한다. DB 백업 및 반출 파일의 보관 위치·기간·복구 책임자는 **운영에서 확인 필요**. 근거: `internal/database/migration/sql/000018_create_vod_jobs.down.sql:1-4`, `internal/vod/sweeper.go:94-119`, `internal/vod/service.go:108-119`. + + ```bash + # VOD_AUDIT_EXPORT는 운영에서 확인한 전용 반출 파일 경로. stdout 출력 금지. + (umask 077; psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 \ + -c 'COPY (SELECT job_id,file_kind,path_hash,deleted_at,reason FROM vod_deletion_logs ORDER BY id) TO STDOUT WITH CSV HEADER' \ + > "${VOD_AUDIT_EXPORT}") + stat -c '%a %s %n' "${VOD_AUDIT_EXPORT}" + sha256sum "${VOD_AUDIT_EXPORT}" + ``` + + 반출 명령의 성공과 내용 완전성을 내부에서 검증한다. checksum만으로 완전성을 보장하지 않는다. journald 보관도 유지한다. 레포의 로그 아카이브는 기본 `/srv/innolive-logs`를 사용하지만 실제 설치·마운트·보관 기간은 **운영에서 확인 필요**. 근거: `deploy/log-archive.sh:13-22,49-56`. + +3. **versioned DB, 현재 19 및 clean 상태가 확인된 경우** 승인된 `golang-migrate` CLI로 `down 1`을 두 번 실행하고 매번 버전을 확인한다. 첫 번째는 000019 down으로 **19 → 18**, 두 번째는 000018 down으로 **18 → 17**이다. 000017 down은 적용하지 않는다. 서버는 Up만 제공하므로 이 CLI는 별도 도구이며 설치·PostgreSQL 지원·운영 접속 방법은 **운영에서 확인 필요**. 근거: `go.mod:8`, `internal/database/migration/versioned.go:8-10,57-63`, `internal/database/migration/sql/000019_add_vod_original_filename.down.sql:1`, `internal/database/migration/sql/000018_create_vod_jobs.down.sql:1-5`. + + ```bash + # 서버 레포 루트. VOD_MIGRATION_DSN은 승인된 비밀 주입으로 준비, 출력/trace 금지. + migrate -path internal/database/migration/sql -database "${VOD_MIGRATION_DSN}" version + # 위 결과가 19, clean인 경우에만 변경 명령 수행. + migrate -path internal/database/migration/sql -database "${VOD_MIGRATION_DSN}" down 1 + migrate -path internal/database/migration/sql -database "${VOD_MIGRATION_DSN}" version + # 18, clean 확인 후 수행. + migrate -path internal/database/migration/sql -database "${VOD_MIGRATION_DSN}" down 1 + migrate -path internal/database/migration/sql -database "${VOD_MIGRATION_DSN}" version + # 최종 17, clean 확인. + ``` + + DSN은 프로세스 인자에 포함될 수 있으므로 승인된 관리 실행 환경에서만 사용하고 명령·오류 전문을 공개 로그에 남기지 않는다. dirty이거나 다른 버전이면 중단하여 실제 스키마와 이력을 조사한다. `force 17`로 SQL 실행을 건너뛰지 않는다. 버전·dirty 점검 도구의 동작은 설치된 CLI 도움말과 대조하며 운영에서 확인한다. + +4. **auto 또는 별도 수동 적용 DB인 경우** 번호 이력을 가정하지 않는다. 앞의 중지·파기·감사 반출을 완료한 후 실제 스키마를 확인해 아래 두 SQL을 같은 순서로 적용한다. 이 방식은 스키마를 VOD 도입 전 형태로 되돌릴 뿐 versioned 이력을 17로 갱신하지 않는다. versioned DB에 이 방법을 혼용하지 않는다. 근거: `internal/vod/store.go:15-27`, `internal/database/migration/migration.go:29-52`, 두 down SQL의 위 근거. + + ```bash + # 변경: auto/수동 적용 DB 전용. 앞 단계가 모두 완료된 경우에만 실행. + psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 --single-transaction \ + -f internal/database/migration/sql/000019_add_vod_original_filename.down.sql \ + -f internal/database/migration/sql/000018_create_vod_jobs.down.sql + ``` + +5. 이전 바이너리(이전 커밋의 컨테이너 이미지)로 복귀한다. 승인된 `VOD_PREVIOUS_SHA`와 이미지 존재 여부는 **운영에서 확인 필요**. 공식 비상 경로의 명령 형태는 아래와 같다. `--force`는 세션 대기를 생략하므로 서버 중지와 영향 확인을 먼저 완료한다. 근거: `deploy/README.md:74-76`, `deploy/apply-release.sh:99-118,124-148,183-193`. + + ```bash + # 변경: 승인된 이전 이미지로 복귀. + sudo /opt/innolive/deploy/apply-release.sh "${VOD_PREVIOUS_SHA}" --force + systemctl is-active innolive-server@blue.service innolive-server@green.service + curl --silent --show-error --fail "${VOD_BASE_URL}/health" --output /dev/null + ``` + + VOD 스키마 down 뒤 새 VOD 바이너리를 다시 시작하면 `auto`/`versioned`에서 스키마를 다시 만들 수 있다. 이전 이미지 선택을 확인한 후 시작하고, health·라이브 정상 동작·VOD 비활성·파일 파기와 외부 감사 증적 보존을 최종 확인한다. 근거: `cmd/server/main.go:72-85`, `internal/database/migration/migration.go:29-52`. SQL down 자체는 디스크 파일을 삭제하거나 감사 로그를 반출하지 않는다: `internal/database/migration/sql/000018_create_vod_jobs.down.sql:1-5`. + +## 5. 확인하지 못한 것 + +- **운영에서 확인 필요**: 실제 `AI_GRPC_TARGETS`/`AI_GRPC_ADDR`, 모든 슬롯과 물리 GPU UUID의 매핑, AI 모드·연결 설정, GPU 여유량과 동시성 조정값. 코드 계약 근거: `internal/config/config.go:183-184,269-273`, `internal/ai/vod.go:28-55`. +- **운영에서 확인 필요**: 공유 경로 실마운트, 워커 UID/GID·서버 GID·umask·ACL, 새 작업 하위 경로의 상호 읽기/쓰기/삭제 권한, 디스크 여유·용량 경보 기준. 근거: `internal/vod/http.go:135-149`, `/workspace/innolive-vod/interface-A.md:285-297`. +- **운영에서 확인 필요**: innolive-ai#83의 실제 포함 SHA와 실행 버전, `VodProcessor` RPC·워커 한도·활성화 상태. 근거: `api/proto/ai_processor.proto:245-261`, `/workspace/innolive-vod/interface-A.md:372-381`. 서버 소스만으로 원격 PR 상태·배포를 입증하지 않음. +- **운영에서 확인 필요**: 각 env의 실효값, DB migration mode·현재 버전·dirty 상태·schema/search_path, 000018/000019 적용 결과, rollback 도구·백업·이전 이미지. 근거: `internal/config/config.go:247-249`, `internal/database/migration/versioned.go:13-63`, `deploy/compose.prod.yaml:27-31`. +- **운영에서 확인 필요**: 프록시/CDN 업로드 크기·timeout·VOD 라우팅, 서비스 URL, 운영 SSH 배포 값·환경 보호 설정·실제 배포 성공 여부. 근거: `internal/vod/http.go:90-103`, `deploy/apply-release.sh:26-30`, `.github/workflows/deploy.yml:28-34,64-77`. +- **운영에서 확인 필요**: 실제 업로드·조회·다운로드·삭제, 라이브 양보·재개·허용 지연, 기한 내 파일 파기·7일 만료, 비활성 시 수동 정리 책임, 감사 로그 반출·보관 위치/기간·아카이브 설치 상태. 근거: `internal/vod/scheduler.go:525-559`, `internal/vod/sweeper.go:14-20,94-158`, `deploy/log-archive.sh:13-22,49-56`. +- 이 문서는 소스와 인터페이스 문서 대조만 수행. go test, 운영 접속(itjzb 포함), push, PR 수정, 머지, 배포, 원격 쓰기 미수행. From 56cfda14128e38ad1cfbbcf36dd4149efb02c6e5 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:03:59 +0900 Subject: [PATCH 18/23] =?UTF-8?q?docs:=20VOD=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EB=A5=BC=20innolive-ai=20=EA=B2=BD=EB=A1=9C?= =?UTF-8?q?=EC=99=80=20ACL=20=EA=B6=8C=ED=95=9C=20=EC=A0=88=EC=B0=A8?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/DEPLOY.md | 37 ++++++++++++++++++++++++++++--------- 1 file changed, 28 insertions(+), 9 deletions(-) diff --git a/api/vod/DEPLOY.md b/api/vod/DEPLOY.md index 13d7bbe..3029719 100644 --- a/api/vod/DEPLOY.md +++ b/api/vod/DEPLOY.md @@ -12,8 +12,8 @@ | 워커 gRPC 주소 | 기존 `AI_GRPC_TARGETS`의 쉼표 구분 주소 목록 재사용. 비어 있으면 `AI_GRPC_ADDR` 하나 사용 | `internal/config/config.go:183-184,269-273,608-615`, `internal/ai/vod_client.go:5-6`. `AI_GRPC_ADDR` 코드 기본값은 `localhost:50051`이나 실제 운영 주소·포트는 **운영에서 확인 필요**. 별도 VOD 주소 env는 이 경로에 없음 | | AI 실행 모드 | `AI_PRIVACY_MODE=real`, 현재 연결 방식은 `AI_GRPC_INSECURE=true` | real일 때만 AI pool 생성, TLS 설정 false는 시작 오류: `cmd/server/main.go:138-150`. 실제 운영 설정은 **운영에서 확인 필요** | | `VOD_WORKER_GPUS` | `<실제 target>=<실제 GPU UUID>,<다른 target>=<실제 GPU UUID>` | 쉼표 구분 `target=GPU-UUID`: `internal/config/vod.go:95-100`, `internal/config/config.go:608-615`. 같은 물리 GPU의 모든 슬롯은 같은 UUID, pool의 모든 target과 교체 슬롯까지 매핑 필요. 누락 시 VOD 배정 중단: `internal/ai/vod.go:28-55`, `internal/vod/scheduler.go:525-535`. 실제 매핑은 **운영에서 확인 필요** | -| `VOD_ROOT` | `/var/lib/innolive/vod` | 서버 기본값: `internal/config/vod.go:35`. 서버와 AI 워커가 같은 호스트의 같은 절대 경로를 공유하고 워커 `--vod-root`/`VOD_ROOT`도 일치해야 함: `/workspace/innolive-vod/interface-A.md:285-297`. 서버 마운트: `deploy/compose.prod.yaml:32-34` | -| 소유자·권한 | 서버 UID `65532`. 서버는 생성·읽기·삭제, 워커는 입력 읽기와 작업/출력 생성·삭제 권한 필요 | `Dockerfile:13-17`, `internal/vod/http.go:135-149`, `internal/vod/sweeper.go:94-119`, `/workspace/innolive-vod/interface-A.md:287-297`. 실제 워커 UID/GID, 서버 GID, ACL, umask는 **운영에서 확인 필요** | +| `VOD_ROOT` | `/var/lib/innolive-ai/vod`를 서버 `server.env`와 AI 워커(`VOD_ROOT`/`--vod-root`) 양쪽에 **명시** | 서버 코드 기본값은 `/var/lib/innolive/vod`(`internal/config/vod.go:35`)라 env를 빼면 경로가 어긋남. AI 워커 유닛이 `ProtectSystem=strict`, `ReadWritePaths=/var/lib/innolive-ai`라 `/var/lib/innolive` 아래에는 쓸 수 없어 공유 경로를 워커 쓰기 가능 경로로 통일(InnoLive PM 결정, 유닛 설정은 운영에서 확인 필요). 서버 컨테이너는 같은 절대 경로로 마운트: `deploy/compose.prod.yaml`의 `/var/lib/innolive-ai/vod:/var/lib/innolive-ai/vod`. 레이아웃 근거: `/workspace/innolive-vod/interface-A.md:285-297` | +| 소유자·권한 | 디렉터리 소유 `innolive-ai`, POSIX ACL로 서버 UID `65532`와 `innolive-ai`에 `rwx`, 같은 내용의 default ACL 설정(아래 (b) 명령) | 공유 그룹+setgid 방식은 워커 umask(보통 022)가 그룹 쓰기를 지워 상대편이 하위 파일을 지울 수 없게 되므로 제외. default ACL이 있으면 새 파일·디렉터리에 umask 대신 ACL이 상속됨. 서버 UID: `Dockerfile:13-17`. **주의:** 서버는 작업 디렉터리를 `0750`으로 만든다(`internal/vod/http.go:137`). 생성 모드가 ACL mask를 `r-x`로 제한해 워커가 그 안에 `work/`·`output.mp4`를 만들 수 없으므로, 기능을 켜기 전에 `0770`으로 바꾸는 코드 수정이 필요(이 PR 범위 밖 후속, 꺼진 상태 머지에는 영향 없음). 원본 파일 `0640`은 워커가 읽기만 하므로 그대로 둠 | | `VOD_MAX_DURATION_SEC` | `3600`초 | `internal/config/vod.go:12-16,68-75,109-120`. 더 작은 양수 허용, 3600 초과는 VOD 활성화 시 서버 시작 오류 | | `VOD_MAX_UPLOAD_BYTES` | `2147483648` B = 2,147,483,648 B = 2 GiB | `internal/config/vod.go:12-16,56-63,109-120`. 더 작은 양수 허용, 워커 고정 상한 초과는 서버 시작 오류. 설정 오류 전파: `internal/config/config.go:252-255`, `cmd/server/main.go:34-37` | | `VOD_RESULT_RETENTION_SEC` | `604800`초 = 7일 | `internal/config/vod.go:35-37,68-75`, `internal/vod/service.go:133-138`. 성공 완료 시 결과 만료 시각 확정 | @@ -70,16 +70,35 @@ env 미설정 시 `printenv`가 값을 출력하지 않을 수 있으므로 표 운영 담당자가 1절 권한 조건에 맞춰 경로를 준비한다. 호스트와 서버 컨테이너, 워커의 mount namespace에서 동일 저장소를 보는지 확인하고, 실제 샘플 작업 생성 후에도 0750/0640 권한을 다시 확인한다. 근거: `deploy/compose.prod.yaml:32-34`, `internal/vod/http.go:135-149`, `/workspace/innolive-vod/interface-A.md:285-297`. ```bash -stat -c '%u:%g %a %n' /var/lib/innolive /var/lib/innolive/vod -namei -l /var/lib/innolive/vod -findmnt -T /var/lib/innolive/vod -df -h /var/lib/innolive/vod +stat -c '%u:%g %a %n' /var/lib/innolive /var/lib/innolive-ai/vod +namei -l /var/lib/innolive-ai/vod +findmnt -T /var/lib/innolive-ai/vod +df -h /var/lib/innolive-ai/vod systemctl show "${VOD_WORKER_UNIT}" --property=User --property=Group --property=UMask docker exec "innolive-server-${VOD_SLOT}" id -docker exec "innolive-server-${VOD_SLOT}" stat -c '%u:%g %a %n' /var/lib/innolive/vod -docker exec "innolive-server-${VOD_SLOT}" sh -c 'test -r /var/lib/innolive/vod && test -w /var/lib/innolive/vod && test -x /var/lib/innolive/vod' +docker exec "innolive-server-${VOD_SLOT}" stat -c '%u:%g %a %n' /var/lib/innolive-ai/vod +docker exec "innolive-server-${VOD_SLOT}" sh -c 'test -r /var/lib/innolive-ai/vod && test -w /var/lib/innolive-ai/vod && test -x /var/lib/innolive-ai/vod' ``` +compose 반영: 레포의 `deploy/compose.prod.yaml`에 `/var/lib/innolive-ai/vod` 마운트를 추가했지만, 운영 유닛은 `/opt/innolive/compose.prod.yaml`을 쓰고(`deploy/innolive-server@.service:30-31`) 배포 워크플로는 이 파일을 복사하지 않는다(`deploy/` 경로는 `.github/workflows/deploy.yml:3-13` paths 밖). 따라서 운영 담당자가 `/opt/innolive/compose.prod.yaml`에 같은 마운트를 수동 반영한 뒤 슬롯을 재기동해야 한다. + +준비 명령(운영 담당자가 호스트에서 실행, 이 PR은 실행하지 않음). 워커 사용자 `innolive-ai`, 서버 컨테이너 UID `65532` 기준이다. 파일시스템이 POSIX ACL을 지원해야 한다(`acl` 패키지의 `setfacl`/`getfacl`). + +```bash +# 1) 디렉터리 생성: 소유자는 워커, 다른 사용자 접근 없음 +sudo install -d -o innolive-ai -g innolive-ai -m 0770 /var/lib/innolive-ai/vod +# 2) 서버 UID가 상위 경로를 통과할 수 있게(목록·쓰기 없이 x만) +sudo setfacl -m u:65532:--x /var/lib/innolive-ai +# 3) 공유 경로 자체 ACL +sudo setfacl -m u:65532:rwx,u:innolive-ai:rwx,m::rwx /var/lib/innolive-ai/vod +# 4) 새 하위 파일·디렉터리에 상속될 default ACL +sudo setfacl -d -m u::rwx,g::---,o::---,u:65532:rwx,u:innolive-ai:rwx,m::rwx /var/lib/innolive-ai/vod +# 5) 확인 +getfacl -p /var/lib/innolive-ai /var/lib/innolive-ai/vod +``` + +ACL은 숫자 UID로 적용되므로 컨테이너 안 UID `65532`에 그대로 적용된다. 서버가 만든 작업 디렉터리의 실효 권한은 `getfacl -p /var/lib/innolive-ai/vod/`의 `mask`와 `#effective`로 확인한다. + 위 루트 디렉터리 확인만으로 작업 하위 디렉터리에 대한 워커 쓰기 권한까지 증명하지는 못한다. 실제 워커 사용자로 동일 경로를 읽고 쓸 수 있는지 스모크 작업의 권한과 함께 확인한다. ### (c) AI 워커의 VodProcessor 지원 확인 @@ -154,7 +173,7 @@ FROM vod_jobs WHERE id = :'vod_job_id'::uuid; SELECT job_id, file_kind, path_hash, deleted_at, reason FROM vod_deletion_logs WHERE job_id = :'vod_job_id'::uuid ORDER BY deleted_at; SQL -find "/var/lib/innolive/vod/${VOD_JOB_ID}" -maxdepth 2 -type f -printf '%P\n' +find "/var/lib/innolive-ai/vod/${VOD_JOB_ID}" -maxdepth 2 -type f -printf '%P\n' journalctl -u 'innolive-server@*' --since '30 minutes ago' --no-pager \ | rg -o 'vod_file_destroyed|vod_retention_worker_unconfirmed|vod retention retry required|vod reconciliation pending|vod scheduling deferred' ``` From 9cf6f67e4d0bb13ad34d120b1dcdebfd0376912f Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:03:59 +0900 Subject: [PATCH 19/23] =?UTF-8?q?chore:=20=EC=9A=B4=EC=98=81=20compose?= =?UTF-8?q?=EC=97=90=20VOD=20=EA=B3=B5=EC=9C=A0=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=A7=88=EC=9A=B4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/compose.prod.yaml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/deploy/compose.prod.yaml b/deploy/compose.prod.yaml index 52919ff..ef4e444 100644 --- a/deploy/compose.prod.yaml +++ b/deploy/compose.prod.yaml @@ -32,6 +32,9 @@ services: volumes: # 참조 얼굴 저장소(REFERENCE_STORE_PATH) - /var/lib/innolive:/var/lib/innolive + # VOD 공유 저장소(VOD_ROOT). AI 워커 유닛의 ReadWritePaths(/var/lib/innolive-ai) 안이며 + # 워커와 같은 절대 경로여야 한다. 권한은 api/vod/DEPLOY.md의 ACL 절차를 따른다. + - /var/lib/innolive-ai/vod:/var/lib/innolive-ai/vod # Apple 로그인 개인키(.p8). 컨테이너 내 경로가 server-secrets.env 의 # APPLE_PRIVATE_KEY_PATH 와 일치해야 하므로 호스트 경로를 그대로 마운트한다. - "${INNOLIVE_APPLE_KEY_PATH:-/dev/null}:${INNOLIVE_APPLE_KEY_PATH:-/dev/null}:ro" From 96c9454874ecc6d8cac5d7669201bbdc999b4664 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:10:47 +0900 Subject: [PATCH 20/23] =?UTF-8?q?test:=20auto=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B0=98=EB=B3=B5=20?= =?UTF-8?q?=EA=B8=B0=EB=8F=99=EA=B3=BC=20VOD=20=EC=8A=A4=ED=82=A4=EB=A7=88?= =?UTF-8?q?=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/database/migration/migration_test.go | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 internal/database/migration/migration_test.go diff --git a/internal/database/migration/migration_test.go b/internal/database/migration/migration_test.go new file mode 100644 index 0000000..7b34ecc --- /dev/null +++ b/internal/database/migration/migration_test.go @@ -0,0 +1,98 @@ +package migration + +import ( + "context" + "net/url" + "os" + "strings" + "testing" + "time" + + "github.com/google/uuid" + "gorm.io/driver/postgres" + "gorm.io/gorm" + gormlogger "gorm.io/gorm/logger" + "inno-live-server/internal/config" + "inno-live-server/internal/vod" +) + +func TestAutoMigrationRepeatedStartup(t *testing.T) { + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL required for PostgreSQL integration") + } + open := func(dsn string) *gorm.DB { + t.Helper() + db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: gormlogger.Default.LogMode(gormlogger.Silent)}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + return db + } + admin := open(dsn) + schema := "migration_test_" + strings.ReplaceAll(uuid.NewString(), "-", "") + if err := admin.Exec("CREATE SCHEMA " + schema).Error; err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := admin.Exec("DROP SCHEMA " + schema + " CASCADE").Error; err != nil { + t.Error(err) + } + }) + u, err := url.Parse(dsn) + if err != nil { + t.Fatal(err) + } + q := u.Query() + q.Set("search_path", schema) + u.RawQuery = q.Encode() + db := open(u.String()) + filename := "original.mp4" + now := time.Now().UTC() + job := vod.Job{ID: uuid.New(), UserID: uuid.New(), Status: "queued", CreatedAt: now, UpdatedAt: now, QueuedAt: now, DurationSec: 60, Version: 1, OriginalFilename: &filename} + log := vod.DeletionLog{JobID: job.ID, FileKind: "work", PathHash: "test-hash", DeletedAt: now, Reason: "test"} + for startup := 1; startup <= 2; startup++ { + // Match main's startup budget, including the entire auto dispatch chain. + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + err := Run(ctx, db, u.String(), config.DatabaseMigrationModeAuto) + cancel() + if err != nil { + t.Fatalf("startup %d: %v", startup, err) + } + for _, table := range []string{"vod_jobs", "vod_deletion_logs"} { + if !db.Migrator().HasTable(table) { + t.Fatalf("startup %d: missing %s", startup, table) + } + } + if !db.Migrator().HasColumn(&vod.Job{}, "original_filename") { + t.Fatalf("startup %d: missing original_filename", startup) + } + if db.Migrator().HasTable("schema_migrations") { + t.Fatal("auto unexpectedly created versioned history") + } + if startup == 1 { + if err := db.Create(&job).Error; err != nil { + t.Fatal(err) + } + if err := db.Create(&log).Error; err != nil { + t.Fatal(err) + } + } + } + var got vod.Job + if err := db.First(&got, "id = ?", job.ID).Error; err != nil { + t.Fatal(err) + } + if got.OriginalFilename == nil || *got.OriginalFilename != filename || got.Status != job.Status || got.Version != job.Version { + t.Fatalf("job changed after repeated startup: %+v", got) + } + var gotLog vod.DeletionLog + if err := db.First(&gotLog, log.ID).Error; err != nil || gotLog.PathHash != log.PathHash { + t.Fatalf("deletion audit changed: %+v, %v", gotLog, err) + } +} From 105c1008ec3485491e4c61a8c67a40e201c60737 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:13:15 +0900 Subject: [PATCH 21/23] =?UTF-8?q?fix:=20VOD=20=EC=83=9D=EC=84=B1=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=EC=9D=98=20=EA=B3=B5=EC=9C=A0=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EA=B6=8C=ED=95=9C=20=EB=B3=B4=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- internal/vod/files.go | 30 +++++++++++++++++ internal/vod/files_test.go | 67 ++++++++++++++++++++++++++++++++++++++ internal/vod/http.go | 10 ++++-- 3 files changed, 105 insertions(+), 2 deletions(-) create mode 100644 internal/vod/files.go create mode 100644 internal/vod/files_test.go diff --git a/internal/vod/files.go b/internal/vod/files.go new file mode 100644 index 0000000..c04a3bd --- /dev/null +++ b/internal/vod/files.go @@ -0,0 +1,30 @@ +package vod + +import ( + "errors" + "os" + "path/filepath" +) + +// mkdirShared sets permissions on every directory we create, including a missing +// VOD root. Existing parents retain their operator-managed ownership and modes. +// Go represents setgid with ModeSetgid, not the Unix numeric 02000 bit. +func mkdirShared(path string) error { + info, err := os.Stat(path) + if err == nil { + if !info.IsDir() { + return &os.PathError{Op: "mkdir", Path: path, Err: errors.New("not a directory")} + } + return nil + } + if !errors.Is(err, os.ErrNotExist) { + return err + } + if err := mkdirShared(filepath.Dir(path)); err != nil { + return err + } + if err := os.Mkdir(path, 0770); err != nil { + return err + } + return os.Chmod(path, os.ModeSetgid|0770) +} diff --git a/internal/vod/files_test.go b/internal/vod/files_test.go new file mode 100644 index 0000000..d1b9eed --- /dev/null +++ b/internal/vod/files_test.go @@ -0,0 +1,67 @@ +package vod + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "syscall" + "testing" + + "github.com/google/uuid" +) + +func TestUploadSharedGroupPermissions(t *testing.T) { + // umask is process-wide. Isolate it from concurrent tests and goroutines. + if os.Getenv("VOD_TEST_UMASK_CHILD") != "1" { + cmd := exec.Command(os.Args[0], "-test.run=^TestUploadSharedGroupPermissions$", "-test.v") + cmd.Env = append(os.Environ(), "VOD_TEST_UMASK_CHILD=1") + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("permission subprocess: %v\n%s", err, output) + } + if os.Getenv("TEST_DATABASE_URL") == "" { + t.Skip("TEST_DATABASE_URL required for upload integration") + } + return + } + for _, mask := range []int{0022, 0077} { + previous := syscall.Umask(mask) + for _, missingRoot := range []bool{false, true} { + s := testService(t) + root := s.Config.Root + if missingRoot { + s.Config.Root = filepath.Join(root, "new", "vod") + } else if err := os.Chmod(root, os.ModeSetgid|0770); err != nil { + t.Fatal(err) + } + s.Probe = fakeProbe{duration: 60} + body, contentType := uploadBody(t, 64, 1) + response := request(t, handler(s), "POST", "/vod/jobs", uuid.New(), body, contentType) + if response.Code != 201 { + t.Fatalf("umask %03o: upload status %d: %s", mask, response.Code, response.Body.String()) + } + jobs, err := s.store.Load().All(context.Background()) + if err != nil || len(jobs) != 1 { + t.Fatalf("jobs: %v, %v", jobs, err) + } + paths := map[string]os.FileMode{ + s.Config.Root: os.ModeSetgid | 0770, + filepath.Dir(s.Path(jobs[0], "original")): os.ModeSetgid | 0770, + s.Path(jobs[0], "original"): 0660, + } + if missingRoot { + paths[filepath.Join(root, "new")] = os.ModeSetgid | 0770 + } + for path, want := range paths { + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if got := info.Mode() & (os.ModePerm | os.ModeSetgid); got != want { + t.Errorf("umask %03o: %s mode %v, want %v", mask, path, got, want) + } + } + } + syscall.Umask(previous) + } +} diff --git a/internal/vod/http.go b/internal/vod/http.go index 287023d..da67c65 100644 --- a/internal/vod/http.go +++ b/internal/vod/http.go @@ -134,7 +134,7 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) { j.OriginalFilename = sanitizeOriginalFilename(filename) path := s.Path(j, "original") dir := filepath.Dir(path) - if e = os.MkdirAll(dir, 0750); e != nil { + if e = mkdirShared(dir); e != nil { fail(w, 503, "service_unavailable") return } @@ -146,11 +146,17 @@ func (s *Service) upload(w http.ResponseWriter, r *http.Request) { } } }() - f, e := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640) + f, e := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0660) if e != nil { fail(w, 503, "service_unavailable") return } + // Creation modes are filtered by umask; the worker must retain group write. + if e = f.Chmod(0660); e != nil { + _ = f.Close() + fail(w, 503, "service_unavailable") + return + } n, e := copyUpload(f, part, maxBytes) var syncErr error if e == nil { From 8875ca6e45d8516c2cde4882034374d2c26efd34 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:13:15 +0900 Subject: [PATCH 22/23] =?UTF-8?q?chore:=20VOD=20=EA=B3=B5=EC=9C=A0=20?= =?UTF-8?q?=EA=B7=B8=EB=A3=B9=20GID=EB=A5=BC=20=EC=9A=B4=EC=98=81=20compos?= =?UTF-8?q?e=EC=97=90=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/compose.prod.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deploy/compose.prod.yaml b/deploy/compose.prod.yaml index ef4e444..bdf2cf0 100644 --- a/deploy/compose.prod.yaml +++ b/deploy/compose.prod.yaml @@ -21,6 +21,9 @@ services: # NVENC 인코딩용 GPU 노출. nvidia 런타임이 libcuda/libnvidia-encode를 # 컨테이너에 주입한다 - 없으면 h264_nvenc가 libcuda.so.1에서 실패한다. runtime: nvidia + # Host innolive-vod GID from deploy.env; numeric fallback keeps VOD-disabled + # installations bootable. Set the real shared GID before enabling VOD. + group_add: ["${INNOLIVE_VOD_GID:-65532}"] environment: NVIDIA_VISIBLE_DEVICES: all NVIDIA_DRIVER_CAPABILITIES: compute,utility,video @@ -33,7 +36,7 @@ services: # 참조 얼굴 저장소(REFERENCE_STORE_PATH) - /var/lib/innolive:/var/lib/innolive # VOD 공유 저장소(VOD_ROOT). AI 워커 유닛의 ReadWritePaths(/var/lib/innolive-ai) 안이며 - # 워커와 같은 절대 경로여야 한다. 권한은 api/vod/DEPLOY.md의 ACL 절차를 따른다. + # 워커와 같은 절대 경로여야 한다. 권한은 api/vod/DEPLOY.md의 공유 그룹 절차를 따른다. - /var/lib/innolive-ai/vod:/var/lib/innolive-ai/vod # Apple 로그인 개인키(.p8). 컨테이너 내 경로가 server-secrets.env 의 # APPLE_PRIVATE_KEY_PATH 와 일치해야 하므로 호스트 경로를 그대로 마운트한다. From 79e177c25c7820a672cbc0272abb49d0567b0a44 Mon Sep 17 00:00:00 2001 From: Geunyoung Chae <186152309+chaeyn@users.noreply.github.com> Date: Mon, 26 Oct 2026 21:17:16 +0900 Subject: [PATCH 23/23] =?UTF-8?q?docs:=20VOD=20auto=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=EA=B3=BC=20=EC=97=85?= =?UTF-8?q?=EB=A1=9C=EB=93=9C=20=EB=B0=8F=20=EA=B3=B5=EC=9C=A0=20=EA=B7=B8?= =?UTF-8?q?=EB=A3=B9=20=EC=9A=B4=EC=98=81=20=EC=A0=88=EC=B0=A8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/vod/DEPLOY.md | 134 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 94 insertions(+), 40 deletions(-) diff --git a/api/vod/DEPLOY.md b/api/vod/DEPLOY.md index 3029719..4d10fee 100644 --- a/api/vod/DEPLOY.md +++ b/api/vod/DEPLOY.md @@ -1,10 +1,10 @@ # VOD 운영 배포 런북 -기준: `feat/vod-jobs/#521`, PR #522, 서버 소스 `b7d5d1b`. 아래 `파일:라인`은 이 소스 기준이며, 절대 경로는 별도 워커 인터페이스 문서다. 명령은 운영 담당자가 실행할 절차 예시이고 이 문서 작성 과정에서 실행한 배포 기록이 아니다. 코드 기본값과 운영 실효값을 구분하며, 확인하지 못한 값은 **운영에서 확인 필요**로 표시한다. +기준: `feat/vod-jobs/#521`, PR #522, 검토 시작 소스 `9cf6f67`. 아래 `파일:라인`은 이 작업의 수정 반영 후 파일 기준이며, 절대 경로는 별도 워커 인터페이스 문서다. 명령은 운영 담당자가 실행할 절차 예시이고 이 문서 작성 과정에서 실행한 배포 기록이 아니다. 코드 기본값과 운영 실효값을 구분하며, 확인하지 못한 값은 **운영에서 확인 필요**로 표시한다. ## 1. 운영 env와 저장소 준비 -서버 env는 `/etc/innolive/server.env`, `server-secrets.env`, `server-slot-${INNOLIVE_SLOT}.env` 순서로 주입되며 뒤의 값이 앞의 값을 덮는다. 비밀 파일 전체를 출력하지 않고 실제 컨테이너의 아래 허용된 키만 확인한다. `docker compose config`는 시크릿을 병합 출력하므로 사용하지 않는다. 근거: `deploy/compose.prod.yaml:10-11,27-31`. +서버 env는 `/etc/innolive/server.env`, `server-secrets.env`, `server-slot-${INNOLIVE_SLOT}.env` 순서로 주입되며 뒤의 값이 앞의 값을 덮는다. 비밀 파일 전체를 출력하지 않고 실제 컨테이너의 아래 허용된 키만 확인한다. `docker compose config` 전체 출력은 시크릿을 병합하므로 금지한다. 검증에는 값이 출력되지 않는 `config --services`만 사용한다. 근거: `deploy/compose.prod.yaml:10-11,30-34`. | 항목 | 활성화 시 기준값 / 설정 방법 | 근거와 운영 확인 | | --- | --- | --- | @@ -13,19 +13,19 @@ | AI 실행 모드 | `AI_PRIVACY_MODE=real`, 현재 연결 방식은 `AI_GRPC_INSECURE=true` | real일 때만 AI pool 생성, TLS 설정 false는 시작 오류: `cmd/server/main.go:138-150`. 실제 운영 설정은 **운영에서 확인 필요** | | `VOD_WORKER_GPUS` | `<실제 target>=<실제 GPU UUID>,<다른 target>=<실제 GPU UUID>` | 쉼표 구분 `target=GPU-UUID`: `internal/config/vod.go:95-100`, `internal/config/config.go:608-615`. 같은 물리 GPU의 모든 슬롯은 같은 UUID, pool의 모든 target과 교체 슬롯까지 매핑 필요. 누락 시 VOD 배정 중단: `internal/ai/vod.go:28-55`, `internal/vod/scheduler.go:525-535`. 실제 매핑은 **운영에서 확인 필요** | | `VOD_ROOT` | `/var/lib/innolive-ai/vod`를 서버 `server.env`와 AI 워커(`VOD_ROOT`/`--vod-root`) 양쪽에 **명시** | 서버 코드 기본값은 `/var/lib/innolive/vod`(`internal/config/vod.go:35`)라 env를 빼면 경로가 어긋남. AI 워커 유닛이 `ProtectSystem=strict`, `ReadWritePaths=/var/lib/innolive-ai`라 `/var/lib/innolive` 아래에는 쓸 수 없어 공유 경로를 워커 쓰기 가능 경로로 통일(InnoLive PM 결정, 유닛 설정은 운영에서 확인 필요). 서버 컨테이너는 같은 절대 경로로 마운트: `deploy/compose.prod.yaml`의 `/var/lib/innolive-ai/vod:/var/lib/innolive-ai/vod`. 레이아웃 근거: `/workspace/innolive-vod/interface-A.md:285-297` | -| 소유자·권한 | 디렉터리 소유 `innolive-ai`, POSIX ACL로 서버 UID `65532`와 `innolive-ai`에 `rwx`, 같은 내용의 default ACL 설정(아래 (b) 명령) | 공유 그룹+setgid 방식은 워커 umask(보통 022)가 그룹 쓰기를 지워 상대편이 하위 파일을 지울 수 없게 되므로 제외. default ACL이 있으면 새 파일·디렉터리에 umask 대신 ACL이 상속됨. 서버 UID: `Dockerfile:13-17`. **주의:** 서버는 작업 디렉터리를 `0750`으로 만든다(`internal/vod/http.go:137`). 생성 모드가 ACL mask를 `r-x`로 제한해 워커가 그 안에 `work/`·`output.mp4`를 만들 수 없으므로, 기능을 켜기 전에 `0770`으로 바꾸는 코드 수정이 필요(이 PR 범위 밖 후속, 꺼진 상태 머지에는 영향 없음). 원본 파일 `0640`은 워커가 읽기만 하므로 그대로 둠 | +| 소유자·권한 | 루트 `root:innolive-vod` 2770, 서버·워커 모두 공유 그룹 가입. 서버 생성 디렉터리 2770, 파일 0660을 생성 후 chmod로 보장 | 서버 UID `65532`: `Dockerfile:13-17`. 그룹 GID는 `INNOLIVE_VOD_GID`로 주입. 워커 `SupplementaryGroups=innolive-vod`, `UMask=0002`와 상위 경로 통과 권한은 아래 (b) 참조. 코드: `internal/vod/files.go:9-29`, `internal/vod/http.go:135-159` | | `VOD_MAX_DURATION_SEC` | `3600`초 | `internal/config/vod.go:12-16,68-75,109-120`. 더 작은 양수 허용, 3600 초과는 VOD 활성화 시 서버 시작 오류 | | `VOD_MAX_UPLOAD_BYTES` | `2147483648` B = 2,147,483,648 B = 2 GiB | `internal/config/vod.go:12-16,56-63,109-120`. 더 작은 양수 허용, 워커 고정 상한 초과는 서버 시작 오류. 설정 오류 전파: `internal/config/config.go:252-255`, `cmd/server/main.go:34-37` | | `VOD_RESULT_RETENTION_SEC` | `604800`초 = 7일 | `internal/config/vod.go:35-37,68-75`, `internal/vod/service.go:133-138`. 성공 완료 시 결과 만료 시각 확정 | | `VOD_ORIGINAL_RETENTION_SEC` | `86400`초 = 24시간, 허용 범위 0..86400 | `internal/config/vod.go:36,68-75,119`, `internal/vod/service.go:121-138`. 종료 후 파기 상한이며 보장 보관 시간이 아님. 종료 확인 후 원본·work·partial을 먼저 정리: `internal/vod/sweeper.go:14-16,43-69` | -| `VOD_SWEEP_INTERVAL` | `1m` | 기본 1분, Go duration 형식, `0 < 값 <= 1h`: `internal/config/vod.go:37,77-87,119`. 다음 기한이 더 가까우면 대기를 단축하고 삭제 시 깨움: `internal/vod/sweeper.go:124-158`, `internal/vod/http.go:332-335` | +| `VOD_SWEEP_INTERVAL` | `1m` | 기본 1분, Go duration 형식, `0 < 값 <= 1h`: `internal/config/vod.go:37,77-87,119`. 다음 기한이 더 가까우면 대기를 단축하고 삭제 시 깨움: `internal/vod/sweeper.go:124-158`, `internal/vod/http.go:340-343` | | `VOD_MIN_FREE_MIB` | `4096` MiB | GPU 여유 메모리 기준이며 디스크 여유 공간 기준이 아님: `internal/config/vod.go:36,56`, `internal/vod/worker.go:120-137`, `internal/ai/vod.go:43-46`. 실제 GPU 여유량·조정값은 **운영에서 확인 필요** | | `VOD_MAX_CONCURRENCY` | `1` | 양의 정수, 전체 VOD lease 수 제한과 GPU당 1개 제한: `internal/config/vod.go:36,89-93,119`, `internal/ai/vod.go:36-45`. 증설 시 실제 용량은 **운영에서 확인 필요** | | `VOD_UPLOAD_TIMEOUT` | `30m` | Go duration 형식, 양수: `internal/config/vod.go:37,77-87,119`. 요청 context와 업로드 read deadline에 적용: `internal/vod/http.go:90-94` | -| 리버스 프록시 업로드 제한 | 전체 multipart를 최소 `2148532224` B까지 전달할 수 있는지 확인 | 서버 본문 상한은 파일 상한 + `1048576` B: `internal/vod/http.go:95-103`. 실제 프록시/CDN의 본문 제한·읽기/응답 timeout·라우팅은 **운영에서 확인 필요**. Caddy 설정 경로는 레포 밖 운영 env에서 읽음: `deploy/apply-release.sh:26-30` | -| `DATABASE_MIGRATION_MODE` | 현재 운영 모드를 유지하며 아래 절차로 확인 | 코드 기본 `auto`: `internal/config/config.go:247-249`. 실제 값은 **운영에서 확인 필요**. 버전 19 확인을 위해 임의로 `auto`를 `versioned`로 바꾸지 않음 | +| 리버스 프록시 업로드 제한 | 전체 multipart를 최소 `2148532224` B까지 전달할 수 있는지 확인 | 서버 본문 상한은 파일 상한 + `1048576` B: `internal/vod/http.go:95-103`. PM 운영 확인: Caddy 사용, `request_body max_size` 미설정(무제한), CDN 없음. Caddy 버전·추가 timeout·라우팅은 **운영에서 확인 필요**. 상세 검토는 2절 참조. Caddy 설정 경로는 레포 밖 운영 env에서 읽음: `deploy/apply-release.sh:26-30` | +| `DATABASE_MIGRATION_MODE` | 운영 `auto` 유지(PM 확인, `/etc/innolive/server.env`) | 코드 기본 `auto`: `internal/config/config.go:247-249`. 버전 19 확인을 위해 임의로 `auto`를 `versioned`로 바꾸지 않음 | -공유 디렉터리는 서버가 작업별 디렉터리를 `0750`, 입력 파일을 `0640`으로 생성한다. 따라서 루트 디렉터리의 그룹 쓰기 권한만 추가해도 워커가 새 작업 디렉터리에 쓸 수 있는 것은 아니다. 동일 UID 실행 또는 생성 후 유효 권한까지 보장하는 운영 권한 구성이 필요하다. 서버가 워커 생성 결과를 읽고 work 하위 파일을 삭제할 수 있는지도 확인한다. 특정 워커 계정이나 GID, 일괄 `chmod` 값을 가정하지 않는다. 근거: `internal/vod/http.go:135-149`, `internal/vod/http.go:371-384`, `internal/vod/sweeper.go:100-106`, `/workspace/innolive-vod/interface-A.md:297`. +서버는 작업 디렉터리와 필요 시 새로 만드는 상위 VOD 디렉터리를 2770, 원본 파일을 0660으로 명시 chmod한다. 기존 부모 디렉터리의 소유·모드는 변경하지 않으므로 공유 루트 사전 준비가 필요하다. 서버 생성 지점 전체 점검 결과 `work/`, `output.mp4`, `output.mp4.part`는 서버가 경로만 전달하고 워커가 만든다. 워커 하위 디렉터리도 그룹 쓰기·실행 권한과 GID 상속을 유지해야 서버가 정리할 수 있다. 근거: `internal/vod/files.go:9-29`, `internal/vod/http.go:135-159`, `internal/vod/service.go:108-119`, `internal/vod/scheduler.go:298`, `internal/vod/sweeper.go:94-106`. 워커 한도 env 이름은 서버와 다르다. 인터페이스의 워커 기본값은 `VOD_MAX_INPUT_BYTES=2147483648`, `VOD_MAX_DURATION_SECONDS=3600`, `VOD_MAX_JOBS=1`이며 마지막 값이 0이면 RPC가 비활성이다. 실제 워커 설정이 서버와 일치하는지는 **운영에서 확인 필요**. 근거: `/workspace/innolive-vod/interface-A.md:372-381`. @@ -45,6 +45,50 @@ main push의 변경 경로가 `api/**`, `cmd/**`, `internal/**`, `go.mod`, `go.s 000018은 `vod_jobs`, 관련 인덱스, `vod_deletion_logs`를 만들고, 000019는 `vod_jobs.original_filename`을 추가한다. 근거: `internal/database/migration/sql/000018_create_vod_jobs.up.sql:1-34`, `internal/database/migration/sql/000019_add_vod_original_filename.up.sql:1`. +### auto 모드 검토 결과 + +운영 `DATABASE_MIGRATION_MODE=auto`는 `/etc/innolive/server.env`에서 주입된 값이라는 PM 확인을 반영한다. 아래는 소스 검토와 로컬 PostgreSQL 17.11 실증이며 운영 DB 스키마·락 대기 상태의 확인을 대신하지 않는다. + +1. **인식·호출:** `auto`는 설정의 기본값이자 허용값이다(`internal/config/config.go:19-21,247-249,490-499`). `main.go`는 DB 연결 후 VOD 활성화 여부와 무관하게 `migration.Run`을 호출하고 auto 분기는 auth → usage → billing → experiencequality → analytics → vod 순으로 실행한다(`cmd/server/main.go:49-85`, `internal/database/migration/migration.go:29-46`). 모드 인식 문제나 VOD 호출 생략은 없으며 코드 수정 불필요다. 단, 앞선 모듈의 SQL 실패는 뒤의 VOD 실행도 중단한다. +2. **생성 객체:** `vod.AutoMigrate`는 embed된 `schema.sql`, `filename.sql`을 한 트랜잭션에서 차례로 실행한다(`internal/vod/store.go:15-27`). `vod_jobs`, 인덱스 3개, `vod_deletion_logs`와 고유 제약은 `internal/vod/schema.sql:1-34`, nullable `original_filename text` 추가는 `internal/vod/filename.sql:1`이다. 실제 전체 auto 실행에서 두 테이블과 컬럼 생성·쓰기 성공을 확인했다(`internal/database/migration/migration_test.go:54-97`). +3. **반복 실행:** 테이블·인덱스·컬럼 모두 `IF NOT EXISTS`를 사용한다. 신규 격리 스키마에서 전체 `Run(auto)`를 30초 기동 제한으로 두 번 실행해 성공했고, 첫 실행 후 넣은 작업·원본 파일명·삭제 감사 기록이 두 번째 실행 후 보존됨을 확인했다(`internal/database/migration/migration_test.go:58-97`). `schema_migrations`가 생성되지 않음도 검증했다. 이는 정상 스키마의 멱등성 실증이며, 기존 객체의 정의가 잘못된 경우를 `IF NOT EXISTS`가 자동 교정하는 것은 아니다. 기존 filename 추가/down 검증도 유지했다(`internal/vod/filename_test.go:326-368`). +4. **기존 테이블·락·재작성:** VOD SQL에는 `REFERENCES`/FK가 없으므로 `users` 등 기존 서비스 테이블에 VOD FK 락을 잡지 않는다(`internal/vod/schema.sql:1-34`). `filename.sql:1`은 기존 `vod_jobs`에도 `ALTER TABLE ADD COLUMN IF NOT EXISTS`를 실행하며, 컬럼이 이미 있어도 `ACCESS EXCLUSIVE` 락 획득이 필요하다. nullable·기본값 없는 text 추가는 테이블 재작성 없이 메타데이터만 추가한다. 일반 CREATE INDEX는 대상 테이블에 `SHARE` 락을 요구하므로 VOD 인덱스가 없는 기존 테이블에는 쓰기 지연이 가능하다. 전체 auto는 별개로 기존 인증 테이블에 `ALTER TABLE ... DROP CONSTRAINT IF EXISTS`를 실행하여 `ACCESS EXCLUSIVE` 락을 잡고, GORM이 모델 차이에 따라 ALTER·인덱스·FK를 추가할 수 있다(`internal/auth/migrate.go:16-29,39-68`, `internal/usage/model.go:99-107`, `internal/billing/model.go:89-97`). 따라서 전체 auto가 기존 테이블을 건드리지 않거나 재작성이 없다고 보장할 수 없다. 기존 테이블에 FK를 추가하면 참조하는 쪽과 **참조받는 테이블 모두 `SHARE ROW EXCLUSIVE`** 락 대상이다. 근거: [PostgreSQL 17 ALTER TABLE](https://www.postgresql.org/docs/17/sql-altertable.html), [명시적 락](https://www.postgresql.org/docs/17/explicit-locking.html). +5. **실패와 blue/green:** DB 연결부터 공유하는 startup context는 30초이며 SQL 오류·락 대기 timeout은 `main.go`의 `os.Exit(1)`로 HTTP listen 전에 종료된다(`cmd/server/main.go:44-46,72-85,670-694`). systemd는 Compose `--exit-code-from server`, `Restart=on-failure`, `RestartSec=15`로 재시도한다(`deploy/innolive-server@.service:27-33`). 배포 스크립트는 새 슬롯 health를 최대 40회(회당 3초 sleep, curl 최대 3초) 확인하고 실패 시 새 슬롯을 stop한 뒤 exit 1한다. 이전 슬롯은 계속 서비스하며 세션 이관·Caddy 전환까지 진행하지 않는다(`deploy/apply-release.sh:47-50,61-63,124-148,153-168`). 첫 배포라 이전 슬롯이 없으면 서비스 가용성은 확보되지 않는다. 실패한 VOD 트랜잭션은 롤백되지만 앞선 다른 모듈의 완료된 변경까지 되돌리는 전역 트랜잭션은 없다(`internal/database/migration/migration.go:29-46`, `internal/vod/store.go:21-27`). +6. **롤백:** auto는 번호 기반 `schema_migrations` 이력을 만들거나 갱신하지 않는다. `versioned`의 19 → 18 → 17 `down 1` 절차를 auto에 적용하거나 버전을 강제 설정하지 않는다. 모든 작성 프로세스 중지·파일 파기·감사 반출 후 실제 스키마를 확인하여 수동 DROP SQL을 적용하는 4절 4번을 따른다. `vod_jobs.original_filename` 제거 후 두 VOD 테이블을 DROP하며 이력을 17로 바꾸지는 않는다. 새 auto 바이너리를 다시 시작하면 재생성되므로 이전 이미지로 복귀한 뒤 시작한다. 근거: `internal/database/migration/versioned.go:13-31,57-63`, `internal/database/migration/sql/000019_add_vod_original_filename.down.sql:1`, `internal/database/migration/sql/000018_create_vod_jobs.down.sql:1-5`. + +### 업로드 크기·시간과 Caddy 검토 + +| 항목 | 실제 코드 값·의미 | 근거 | +| --- | --- | --- | +| HTTP `ReadTimeout` | 미설정 0, 본문 전체 읽기 제한 없음 | `cmd/server/main.go:670-675` | +| HTTP `ReadHeaderTimeout` | 10초, 헤더 수신 제한이며 업로드 본문 시간 제한 아님 | `cmd/server/main.go:673` | +| HTTP `WriteTimeout` | 미설정 0, 응답 쓰기 제한 없음 | `cmd/server/main.go:670-675` | +| HTTP `IdleTimeout` | 60초, keep-alive에서 다음 요청을 기다리는 시간 | `cmd/server/main.go:674` | +| VOD read deadline·context | 핸들러 진입 후 인증·store 확인을 통과한 시점부터 `VOD_UPLOAD_TIMEOUT`, 기본 30분. 종료 시 read deadline 초기화 | `internal/vod/http.go:85-94`, `internal/config/vod.go:34-37,77-87` | +| VOD upload write deadline | 업로드 핸들러는 설정하지 않음(전역 0 유지). 결과 다운로드의 별도 write deadline과 구분 | `internal/vod/http.go:85-232,402-403` | +| 본문·파일 상한 | `MaxBytesReader` 및 Content-Length 검사 = 파일 상한 2,147,483,648 + 여유 1,048,576 = **2,148,532,224 B**. multipart epilogue도 검사 | `internal/vod/http.go:95-103,160-193`, `internal/config/vod.go:12-16` | + +Go timeout 의미는 [net/http Server](https://pkg.go.dev/net/http#Server) 기준이다. ResponseController가 실제 writer에 도달하도록 request ID wrapper는 `Unwrap`을 제공한다(`internal/server/server.go:1453-1458`). 전역 ReadTimeout/WriteTimeout이 30분보다 짧은 문제는 없으므로 **서버 timeout 코드 수정은 하지 않았다**. 2,148,532,224 B는 multipart 전체 상한이고 파일 자체는 2 GiB 이하여야 한다. + +**30분 전송 완료를 무조건 보장하지는 않는다.** 현재 30분 context는 본문 수신 후 ffprobe와 DB 저장까지 포함한다(`internal/vod/http.go:200-226`, `internal/vod/service.go:32-34`). 30분 경계에 도착하면 deadline 초과로 수신 또는 저장이 실패할 수 있다. 본문 전송만 최대 30분이 필요한 운영에서는 `VOD_UPLOAD_TIMEOUT`에 후처리 여유를 포함한 값(예: `35m`)을 명시하고 실제 ffprobe·DB 지연으로 조정한다. 클라이언트·프록시의 끊김, 배포 중 재시작까지 막는 보장은 아니며, 실제 2 GiB·30분 전송 실험은 수행하지 않았다. + +PM 운영 확인: Caddy 사용, `request_body max_size` 미설정(무제한), CDN 없음. 레포 검색에서 전체 운영 Caddyfile은 없고 `deploy/oauth/innolive-oauth.caddy:1-27`의 OAuth 정적 handler 조각만 확인했다(수정 없음). 실제 운영 Caddyfile은 `deploy.env`의 `CADDYFILE`로 지정하고 배포 중 기존 파일의 reverse_proxy 포트만 치환한다(`deploy/apply-release.sh:26-30,82-93`). + +[Caddy reverse_proxy 공식 문서](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#streaming) 기준 HTTP 요청 본문은 `request_buffers` 미설정 시 전체 버퍼링 없이 스트리밍 전달한다. `request_buffers`를 지정하면 해당 크기까지 먼저 읽고 upstream에 전송한다. 기본 응답의 부분 버퍼링·`flush_interval`과 요청 본문 버퍼링은 구분한다. [HTTP transport 기본값](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#the-http-transport)은 `dial_timeout=3s`, `response_header_timeout`, `read_timeout`, `write_timeout`은 제한 없음이다. dial timeout은 upstream 연결 시간으로 전체 업로드 시간이 아니다. + +[Caddy servers timeouts 공식 문서](https://caddyserver.com/docs/caddyfile/options#timeouts) 기준 `read_body`·`write` 기본은 제한 없음, `read_header=1m`, `idle=5m`이다. 현재 문서에는 별도 `read_body_idle`·`write_idle` 기본 1분도 있으므로 전체 30분 제한과 중간 정체 제한을 구분한다. 운영 Caddy 버전, 해당 옵션 지원·실효값, `request_buffers` 미설정 여부 및 추가 timeout 설정은 **운영에서 확인 필요**다. 운영 값이 모두 문서 기본값이라는 가정으로 검증 완료 처리하지 않는다. + +### VOD 꺼진 상태 자동 배포 경로에서 바뀌는 것 + +`VOD_ENABLED=false`이면 업로드 라우트·워커 배정·스위퍼는 실행되지 않으므로 이번 파일 모드 변경은 새 VOD 파일 생성에 사용되지 않는다(`internal/server/vod.go:22-32`). 그러나 코드·이 문서 경로는 자동 배포 대상이며 새 이미지 부팅 때 auto 마이그레이션은 실행된다. `deploy.yml` paths에는 **`deploy/`가 없고**, 이미지 배포 workflow는 운영 compose를 복사하지 않는다(`.github/workflows/deploy.yml:3-13,44-77`). 운영 유닛은 **`/opt/innolive/compose.prod.yaml`**을 사용하므로 레포의 group_add/기존 VOD 마운트 변경은 이미지 자동 배포로 반영되지 않는다(`deploy/innolive-server@.service:25-31`). 운영자가 수동 반영할 때만 컨테이너 재생성으로 적용되며, VOD 비활성이어도 새 마운트와 보조 그룹은 컨테이너에 적용된다. 사전 디렉터리 생성과 GID 설정 순서는 3절 (b)를 따른다. + +### 로컬 검증 결과 + +- PostgreSQL **17.11**, `127.0.0.1:55433/vod_test`, `TEST_DATABASE_URL=postgres://box@127.0.0.1:55433/vod_test?sslmode=disable` 사용. 테스트별 격리 스키마 생성 후 정리, 운영 DB 사용 없음. +- `go build ./...`, `go vet ./...` 성공. 같은 DB 환경의 `go test -race -count=1 -json ./...` 성공: 테스트 있는 **16개 패키지 통과**, 테스트 없는 4개 패키지, 최상위 **947개 통과·1개 skip**(하위 테스트 포함 1,407개 통과·1개 skip). skip은 `INNOLIVE_LOCAL_DEVICE_PROBE=1`이 설정되지 않은 실제 기기 테스트 `TestLocalDeviceMediaProbe`다. +- 신규 auto 통합 테스트 1개에서 전체 마이그레이션 **2회** 성공·데이터 보존·이력 미생성 확인. 신규 권한 테스트 1개는 별도 프로세스에서 **umask 022/077 × 루트 기존/미생성 4가지 조건**으로 실제 업로드 후 2770/0660 확인. 기존 2 GiB 파일 경계 테스트는 exact/plus_one/unbounded_tail **3가지 조건** 통과(`internal/vod/upload_limit_test.go:26-52`), 네트워크 30분 시험과 구분. +- `INNOLIVE_SLOT=blue INNOLIVE_IMAGE=x INNOLIVE_TAG=y docker compose -f deploy/compose.prod.yaml config --services` 성공(`server`만 출력). 실제 시크릿 env 파일 없이 서비스 이름 출력으로 파싱 확인, 전체 config 출력 없음. `git diff --check` 통과, 이 문서 em dash 없음. + ## 3. 기능 켜는 순서 아래 명령의 `VOD_SLOT`, `VOD_BASE_URL`, `VOD_WORKER_TARGET`, `VOD_WORKER_UNIT`, `VOD_PG_SERVICE`, `VOD_AUTH_CONFIG`, `VOD_SAMPLE`, `VOD_SMOKE_DIR`, `VOD_JOB_ID`는 운영 담당자가 확인한 값으로 설정한다. 모두 **운영에서 확인 필요**이며 임의의 주소·계정·토큰을 예시에 넣지 않는다. DB는 인증 정보를 별도 보관한 libpq service, HTTP는 권한 0600인 curl 설정 파일의 Authorization 헤더를 이용하는 절차 예시다. 도구 설치 여부와 이 인증 파일의 준비 방식도 **운영에서 확인 필요**. `set -x`, env 전체 출력, curl verbose/trace를 사용하지 않는다. @@ -65,41 +109,48 @@ psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 -c "SELECT column_name FR env 미설정 시 `printenv`가 값을 출력하지 않을 수 있으므로 표의 코드 기본값과 구분한다. `public`은 확인 쿼리 예시이며 실제 DB schema/search_path는 **운영에서 확인 필요**. `versioned`에서는 승인된 migrate 도구의 `version`으로 19 및 clean 상태를 확인한다(4절). `auto`에서는 위 객체 확인을 사용한다. health 성공만으로 VOD 스키마를 확인했다고 판단하지 않는다. 슬롯·health 근거: `deploy/apply-release.sh:61-62,99-108`; 스키마 근거: `internal/database/migration/sql/000018_create_vod_jobs.up.sql:1-34`, `internal/database/migration/sql/000019_add_vod_original_filename.up.sql:1`. -### (b) 공유 경로와 소유자·권한 준비 +### (b) 공유 그룹과 경로 준비 -운영 담당자가 1절 권한 조건에 맞춰 경로를 준비한다. 호스트와 서버 컨테이너, 워커의 mount namespace에서 동일 저장소를 보는지 확인하고, 실제 샘플 작업 생성 후에도 0750/0640 권한을 다시 확인한다. 근거: `deploy/compose.prod.yaml:32-34`, `internal/vod/http.go:135-149`, `/workspace/innolive-vod/interface-A.md:285-297`. +공유 권한은 ACL 대신 공유 그룹 방식으로 결정했다(PM 운영 확인). 순서는 **호스트 그룹·디렉터리 사전 생성 → 워커 drop-in 및 deploy.env 설정 → 운영 compose 수동 반영 → 컨테이너 재생성 → 실권한 확인 → VOD 활성화**다. 다음은 운영 담당자가 수행할 예시이며 이 작업에서 실행하지 않는다. ```bash -stat -c '%u:%g %a %n' /var/lib/innolive /var/lib/innolive-ai/vod +sudo groupadd --system innolive-vod +sudo usermod -aG innolive-vod innolive-ai +sudo install -d -o root -g innolive-vod -m 2770 /var/lib/innolive-ai/vod +getent group innolive-vod +stat -c '%u:%g %a %n' /var/lib/innolive-ai /var/lib/innolive-ai/vod namei -l /var/lib/innolive-ai/vod findmnt -T /var/lib/innolive-ai/vod df -h /var/lib/innolive-ai/vod -systemctl show "${VOD_WORKER_UNIT}" --property=User --property=Group --property=UMask -docker exec "innolive-server-${VOD_SLOT}" id -docker exec "innolive-server-${VOD_SLOT}" stat -c '%u:%g %a %n' /var/lib/innolive-ai/vod -docker exec "innolive-server-${VOD_SLOT}" sh -c 'test -r /var/lib/innolive-ai/vod && test -w /var/lib/innolive-ai/vod && test -x /var/lib/innolive-ai/vod' ``` -compose 반영: 레포의 `deploy/compose.prod.yaml`에 `/var/lib/innolive-ai/vod` 마운트를 추가했지만, 운영 유닛은 `/opt/innolive/compose.prod.yaml`을 쓰고(`deploy/innolive-server@.service:30-31`) 배포 워크플로는 이 파일을 복사하지 않는다(`deploy/` 경로는 `.github/workflows/deploy.yml:3-13` paths 밖). 따라서 운영 담당자가 `/opt/innolive/compose.prod.yaml`에 같은 마운트를 수동 반영한 뒤 슬롯을 재기동해야 한다. +이미 그룹이 있으면 기존 GID를 사용한다. 상위 `/var/lib/innolive-ai`의 현재 소유·모드는 **운영에서 확인 필요**다. 서버 UID 65532가 경로를 통과할 x 권한이 필요하다. 기존 워커 권한 영향을 확인한 뒤 상위 디렉터리 그룹을 `innolive-vod`로 지정하고 `g+x`를 부여하거나, 그룹 변경이 부적절하면 `chmod o+x /var/lib/innolive-ai`로 통과 권한만 부여한다. 목록 읽기·쓰기 권한을 others에 추가하지 않는다. 컨테이너 mount namespace에서도 같은 경로에 대한 통과 권한을 별도 확인한다. -준비 명령(운영 담당자가 호스트에서 실행, 이 PR은 실행하지 않음). 워커 사용자 `innolive-ai`, 서버 컨테이너 UID `65532` 기준이다. 파일시스템이 POSIX ACL을 지원해야 한다(`acl` 패키지의 `setfacl`/`getfacl`). +**주의:** bind mount 원본이 없으면 Docker가 `root:root` 0755 디렉터리를 만들 수 있다. 이 경우 서버와 워커가 쓸 수 없으므로 compose 기동보다 위 `install -d`를 먼저 수행한다. 생성 함수의 chmod는 운영자가 이미 만든 루트의 소유권이나 GID를 교정하지 않는다. 근거: `deploy/compose.prod.yaml:35-40`, `internal/vod/files.go:13-18`. -```bash -# 1) 디렉터리 생성: 소유자는 워커, 다른 사용자 접근 없음 -sudo install -d -o innolive-ai -g innolive-ai -m 0770 /var/lib/innolive-ai/vod -# 2) 서버 UID가 상위 경로를 통과할 수 있게(목록·쓰기 없이 x만) -sudo setfacl -m u:65532:--x /var/lib/innolive-ai -# 3) 공유 경로 자체 ACL -sudo setfacl -m u:65532:rwx,u:innolive-ai:rwx,m::rwx /var/lib/innolive-ai/vod -# 4) 새 하위 파일·디렉터리에 상속될 default ACL -sudo setfacl -d -m u::rwx,g::---,o::---,u:65532:rwx,u:innolive-ai:rwx,m::rwx /var/lib/innolive-ai/vod -# 5) 확인 -getfacl -p /var/lib/innolive-ai /var/lib/innolive-ai/vod +워커 유닛 drop-in `/etc/systemd/system/.d/vod.conf` 예시: + +```ini +[Service] +SupplementaryGroups=innolive-vod +UMask=0002 ``` -ACL은 숫자 UID로 적용되므로 컨테이너 안 UID `65532`에 그대로 적용된다. 서버가 만든 작업 디렉터리의 실효 권한은 `getfacl -p /var/lib/innolive-ai/vod/`의 `mask`와 `#effective`로 확인한다. +워커가 이미 `ReadWritePaths=/var/lib/innolive-ai`를 허용한다는 PM 전달 기준으로 VOD는 그 하위이므로 추가 `ReadWritePaths`는 불필요하다. 실제 유닛·drop-in 합성 결과는 **운영에서 확인 필요**다. 워커 코드가 명시 chmod로 그룹 쓰기를 보장하지 않으면 `UMask=0002`가 필요하다. 일반적인 파일 0666·디렉터리 0777 생성 호출에서 각각 0664·2775(setgid 상속)가 되어 그룹 쓰기가 유지된다. UMask는 명시적으로 0600/0700으로 생성하는 코드를 넓혀 주지 않으므로 이 경우 워커 코드의 chmod가 필요하다. 서버의 0660/2770 보장과 워커의 생성 모드는 구분한다. + +umask 022로 워커 하위 디렉터리가 2755가 되면 서버가 그 안의 파일을 삭제할 수 없다. 정확히는 파일 삭제에는 **부모 디렉터리의 w+x**가 필요하고 파일 자체의 쓰기 권한이 필수는 아니다. 파일 그룹 쓰기는 상호 수정용이고, 재귀 삭제를 위해서는 모든 워커 하위 디렉터리의 그룹 w+x가 필요하다. 워커 명시 chmod 여부와 결과·work 하위 GID/모드는 **운영에서 확인 필요**다. + +`getent group innolive-vod`로 확인한 숫자 GID를 비밀이 아닌 설정 `INNOLIVE_VOD_GID=<실제 GID>`로 `/etc/innolive/deploy.env`에 넣는다. systemd의 `EnvironmentFile`이 Compose 치환 변수로 전달한다(`deploy/innolive-server@.service:25-30`). 컨테이너용 `server.env`의 env_file과는 용도가 다르다. Compose `group_add: ["${INNOLIVE_VOD_GID:-65532}"]`는 숫자 GID를 사용하므로 이미지 안에 같은 그룹명이 없어도 된다. 필수 변수 `:?`는 기존 VOD 비활성 설치도 기동 실패시키므로 쓰지 않는다. 기본값 65532는 root 그룹 0을 부여하지 않고 변수 미설정·빈 값에서도 Compose를 시작시키기 위한 값이며, 실제 공유 권한을 보장하는 값은 아니다. 호스트의 해당 숫자 GID 용도와 실제 공유 GID를 확인하고 활성화 전 반드시 덮어쓴다. 근거: `deploy/compose.prod.yaml:24-26`, `Dockerfile:13-17`. + +drop-in과 env·운영 compose 적용 후 운영 담당자가 대상 유닛·비활성 슬롯을 확인해 실행할 명령 형태: + +```bash +sudo systemctl daemon-reload +sudo systemctl restart "${VOD_WORKER_UNIT}" +sudo systemctl restart "innolive-server@${VOD_SLOT}.service" +``` -위 루트 디렉터리 확인만으로 작업 하위 디렉터리에 대한 워커 쓰기 권한까지 증명하지는 못한다. 실제 워커 사용자로 동일 경로를 읽고 쓸 수 있는지 스모크 작업의 권한과 함께 확인한다. +서버 유닛의 ExecStop은 compose down, ExecStart는 up이므로 이 restart는 컨테이너를 재생성한다(`deploy/innolive-server@.service:30-31`). `group_add`와 마운트 변경에는 컨테이너 재생성이 필요하다. 워커 재시작은 라이브에 영향을 줄 수 있으므로 기존 운영 순서를 따른다. 적용 후 `systemctl show "${VOD_WORKER_UNIT}" --property=SupplementaryGroups --property=UMask --property=ReadWritePaths`, `docker exec "innolive-server-${VOD_SLOT}" id`로 확인한다. 서버와 워커 양쪽에서 공유 루트·실제 새 작업·work·결과의 그룹 읽기/쓰기/통과/삭제를 스모크 검증한다. ### (c) AI 워커의 VodProcessor 지원 확인 @@ -117,7 +168,7 @@ docker exec "innolive-server-${VOD_SLOT}" nvidia-smi --query-gpu=uuid,memory.fre ### (d) env 설정 후 재시작 -운영 담당자가 1절 값을 설정하고 기존 슬롯 운영 절차로 서버를 재기동한다. 단일 슬롯 재시작도 라이브에 영향을 줄 수 있으므로 현재 활성 슬롯과 세션 상태를 확인한다. 여기서는 재시작을 실행하지 않는다. env 반영은 compose가 컨테이너를 기동하는 시점이며 적용 후 (a)의 health·스키마 확인과 아래 실효값 확인을 반복한다. 근거: `deploy/innolive-server@.service:25-31`, `deploy/compose.prod.yaml:27-31`, `deploy/apply-release.sh:124-148,183-193`. +운영 담당자가 1절 값을 설정하고 기존 슬롯 운영 절차로 서버를 재기동한다. 단일 슬롯 재시작도 라이브에 영향을 줄 수 있으므로 현재 활성 슬롯과 세션 상태를 확인한다. 여기서는 재시작을 실행하지 않는다. env 반영은 compose가 컨테이너를 기동하는 시점이며 적용 후 (a)의 health·스키마 확인과 아래 실효값 확인을 반복한다. 근거: `deploy/innolive-server@.service:25-31`, `deploy/compose.prod.yaml:30-34`, `deploy/apply-release.sh:124-148,183-193`. ```bash docker exec "innolive-server-${VOD_SLOT}" printenv \ @@ -158,7 +209,7 @@ curl --silent --show-error --config "${VOD_AUTH_CONFIG}" \ --output /dev/null --write-out '%{http_code}\n' "${VOD_BASE_URL}/vod/jobs/${VOD_JOB_ID}/result" ``` -기대값은 업로드 201, 상태 조회 200, 완료 후 다운로드 200, 삭제 204, 삭제 후 상세·결과 404다. DELETE 204는 물리 파기 완료가 아니므로 (f)를 확인한다. 원본 파기 시각과 결과 만료 시각도 확인한다. 근거: `internal/vod/http.go:218-224,226-239,319-348,350-422`, `internal/vod/store.go:84-86`, `internal/vod/service.go:121-138`; 폴링 간격 계약: `api/vod/openapi.yaml:99-103`. +기대값은 업로드 201, 상태 조회 200, 완료 후 다운로드 200, 삭제 204, 삭제 후 상세·결과 404다. DELETE 204는 물리 파기 완료가 아니므로 (f)를 확인한다. 원본 파기 시각과 결과 만료 시각도 확인한다. 근거: `internal/vod/http.go:226-232,234-247,327-356,358-430`, `internal/vod/store.go:84-86`, `internal/vod/service.go:121-138`; 폴링 간격 계약: `api/vod/openapi.yaml:99-103`. 라이브 우선은 삭제 전 처리 중인 테스트 작업으로 별도 확인한다. 같은 GPU에 승인된 테스트 라이브 수요를 발생시키고 VOD가 paused/대기로 양보하며 라이브가 정상 지속되는지 관찰한다. 라이브 종료 후 자원이 비면 재개·완료되는지도 위 GET과 워커 List RPC로 확인한다. 라이브 생성 방법·허용 지연·부하 기준은 **운영에서 확인 필요**. 근거: `internal/ai/pool.go:233,280`, `internal/ai/vod.go:43-55,73-82`, `internal/vod/scheduler.go:525-559`, `/workspace/innolive-vod/interface-A.md:239-244`. @@ -182,7 +233,7 @@ journalctl -u 'innolive-server@*' --since '30 minutes ago' --no-pager \ DB의 `file_kind`, 상대 경로 SHA-256인 `path_hash`, `deleted_at`, `reason`과 실제 파일 부재를 대조한다. 위 로그 명령은 비밀 정보 노출을 피하기 위해 이벤트 이름만 출력하며, 스모크 ID별 증적은 DB 조회를 사용한다. 빈 작업 디렉터리가 남는 것과 파일 파기는 구분한다. 근거: `internal/vod/service.go:108-119`, `internal/vod/sweeper.go:94-120`, `internal/vod/store.go:110-111`. -원본은 24시간을 기다리지 않고 먼저 삭제될 수 있다. 실제 7일 결과 만료와 기한 직전 다운로드 차단은 아직 도래하지 않은 시점에 검증 완료로 표시하지 않는다. 정리 실패·워커 미확인·비활성 기간의 수동 파기 담당자와 대응시간, 로그 보관 기간은 **운영에서 확인 필요**. 근거: `internal/vod/sweeper.go:14-16,46-53,124-158`, `internal/vod/http.go:355-357,393-426`. +원본은 24시간을 기다리지 않고 먼저 삭제될 수 있다. 실제 7일 결과 만료와 기한 직전 다운로드 차단은 아직 도래하지 않은 시점에 검증 완료로 표시하지 않는다. 정리 실패·워커 미확인·비활성 기간의 수동 파기 담당자와 대응시간, 로그 보관 기간은 **운영에서 확인 필요**. 근거: `internal/vod/sweeper.go:14-16,46-53,124-158`, `internal/vod/http.go:363-365,401-434`. ### (g) 문제 시 즉시 끄기 @@ -240,13 +291,16 @@ grpcurl -plaintext -import-path api/proto -proto ai_processor.proto \ DSN은 프로세스 인자에 포함될 수 있으므로 승인된 관리 실행 환경에서만 사용하고 명령·오류 전문을 공개 로그에 남기지 않는다. dirty이거나 다른 버전이면 중단하여 실제 스키마와 이력을 조사한다. `force 17`로 SQL 실행을 건너뛰지 않는다. 버전·dirty 점검 도구의 동작은 설치된 CLI 도움말과 대조하며 운영에서 확인한다. -4. **auto 또는 별도 수동 적용 DB인 경우** 번호 이력을 가정하지 않는다. 앞의 중지·파기·감사 반출을 완료한 후 실제 스키마를 확인해 아래 두 SQL을 같은 순서로 적용한다. 이 방식은 스키마를 VOD 도입 전 형태로 되돌릴 뿐 versioned 이력을 17로 갱신하지 않는다. versioned DB에 이 방법을 혼용하지 않는다. 근거: `internal/vod/store.go:15-27`, `internal/database/migration/migration.go:29-52`, 두 down SQL의 위 근거. +4. **auto 또는 별도 수동 적용 DB인 경우** 번호 이력을 가정하지 않는다. 앞의 중지·파기·감사 반출을 완료한 후 실제 스키마를 확인해 아래 수동 SQL을 순서대로 적용한다(두 down 파일의 DROP 내용과 동일하며 테이블 부재도 허용). 이 방식은 스키마를 VOD 도입 전 형태로 되돌릴 뿐 versioned 이력을 17로 갱신하지 않는다. versioned DB에 이 방법을 혼용하지 않는다. 근거: `internal/vod/store.go:15-27`, `internal/database/migration/migration.go:29-52`, 두 down SQL의 위 근거. ```bash # 변경: auto/수동 적용 DB 전용. 앞 단계가 모두 완료된 경우에만 실행. - psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 --single-transaction \ - -f internal/database/migration/sql/000019_add_vod_original_filename.down.sql \ - -f internal/database/migration/sql/000018_create_vod_jobs.down.sql + psql "service=${VOD_PG_SERVICE}" -X -v ON_ERROR_STOP=1 --single-transaction <<'SQL' + -- 실제 search_path가 VOD 스키마인지 먼저 확인. CASCADE 사용 금지. + ALTER TABLE IF EXISTS vod_jobs DROP COLUMN IF EXISTS original_filename; + DROP TABLE IF EXISTS vod_deletion_logs; + DROP TABLE IF EXISTS vod_jobs; + SQL ``` 5. 이전 바이너리(이전 커밋의 컨테이너 이미지)로 복귀한다. 승인된 `VOD_PREVIOUS_SHA`와 이미지 존재 여부는 **운영에서 확인 필요**. 공식 비상 경로의 명령 형태는 아래와 같다. `--force`는 세션 대기를 생략하므로 서버 중지와 영향 확인을 먼저 완료한다. 근거: `deploy/README.md:74-76`, `deploy/apply-release.sh:99-118,124-148,183-193`. @@ -263,9 +317,9 @@ grpcurl -plaintext -import-path api/proto -proto ai_processor.proto \ ## 5. 확인하지 못한 것 - **운영에서 확인 필요**: 실제 `AI_GRPC_TARGETS`/`AI_GRPC_ADDR`, 모든 슬롯과 물리 GPU UUID의 매핑, AI 모드·연결 설정, GPU 여유량과 동시성 조정값. 코드 계약 근거: `internal/config/config.go:183-184,269-273`, `internal/ai/vod.go:28-55`. -- **운영에서 확인 필요**: 공유 경로 실마운트, 워커 UID/GID·서버 GID·umask·ACL, 새 작업 하위 경로의 상호 읽기/쓰기/삭제 권한, 디스크 여유·용량 경보 기준. 근거: `internal/vod/http.go:135-149`, `/workspace/innolive-vod/interface-A.md:285-297`. +- **운영에서 확인 필요**: 공유 경로 실마운트, 공유 그룹 GID·상위 경로 통과 권한·워커 chmod/umask, 새 작업 하위 경로의 상호 읽기/쓰기/삭제 권한, 디스크 여유·용량 경보 기준. 근거: `internal/vod/http.go:135-149`, `/workspace/innolive-vod/interface-A.md:285-297`. - **운영에서 확인 필요**: innolive-ai#83의 실제 포함 SHA와 실행 버전, `VodProcessor` RPC·워커 한도·활성화 상태. 근거: `api/proto/ai_processor.proto:245-261`, `/workspace/innolive-vod/interface-A.md:372-381`. 서버 소스만으로 원격 PR 상태·배포를 입증하지 않음. -- **운영에서 확인 필요**: 각 env의 실효값, DB migration mode·현재 버전·dirty 상태·schema/search_path, 000018/000019 적용 결과, rollback 도구·백업·이전 이미지. 근거: `internal/config/config.go:247-249`, `internal/database/migration/versioned.go:13-63`, `deploy/compose.prod.yaml:27-31`. -- **운영에서 확인 필요**: 프록시/CDN 업로드 크기·timeout·VOD 라우팅, 서비스 URL, 운영 SSH 배포 값·환경 보호 설정·실제 배포 성공 여부. 근거: `internal/vod/http.go:90-103`, `deploy/apply-release.sh:26-30`, `.github/workflows/deploy.yml:28-34,64-77`. +- **운영에서 확인 필요**: 운영 확인된 auto 외 env 실효값, DB schema/search_path(기존 versioned 이력이 있다면 버전·dirty 상태), 000018/000019 적용 결과, rollback 도구·백업·이전 이미지. 근거: `internal/config/config.go:247-249`, `internal/database/migration/versioned.go:13-63`, `deploy/compose.prod.yaml:30-34`. +- **운영에서 확인 필요**: Caddy 버전·추가 timeout·request_buffers·VOD 라우팅(PM 확인: max_size 미설정, CDN 없음), 서비스 URL, 운영 SSH 배포 값·환경 보호 설정·실제 배포 성공 여부. 근거: `internal/vod/http.go:90-103`, `deploy/apply-release.sh:26-30`, `.github/workflows/deploy.yml:28-34,64-77`. - **운영에서 확인 필요**: 실제 업로드·조회·다운로드·삭제, 라이브 양보·재개·허용 지연, 기한 내 파일 파기·7일 만료, 비활성 시 수동 정리 책임, 감사 로그 반출·보관 위치/기간·아카이브 설치 상태. 근거: `internal/vod/scheduler.go:525-559`, `internal/vod/sweeper.go:14-20,94-158`, `deploy/log-archive.sh:13-22,49-56`. -- 이 문서는 소스와 인터페이스 문서 대조만 수행. go test, 운영 접속(itjzb 포함), push, PR 수정, 머지, 배포, 원격 쓰기 미수행. +- 로컬 검증 결과는 2절에 기록. 운영 접속(itjzb 포함), push, PR 수정, 머지, 배포, 원격 쓰기 미수행.