# Task Scheduler — Kneron Model Converter Phase 1 Kneron Model Converter 的 Job 管理與 queue orchestration 服務。負責接收上游 (visionA-backend / Web UI)的轉檔請求,協調 ONNX → BIE → NEF pipeline,並把成功 的結果檔 promote 到 File Access Agent / NAS 模型庫。 > **Phase 1 對外 API 完整規格** → 見 `docs/openapi.yaml` --- ## 1. 專案介紹 ### 1.1 服務角色 ``` public Internet internal ↓ visionA-backend ─→ Nginx (443, public vhost) ─→ /api/v1/* ─→ task-scheduler ─→ Worker │ Web UI ─→ Nginx (80, internal vhost) ─→ /jobs ──┘ │ ↓ ONNX → BIE → NEF ↓ MinIO Bucket ↓ POST /api/v1/jobs/:id/promote ↓ File Access Agent ↓ NAS 模型庫 ``` task-scheduler 是 Phase 1 唯一暴露給上游的應用層元件,承擔: - 對外 API(**Phase 1 新增**):`/api/v1/*` 共 4 個端點 + 2 個 Phase 2 預留 - 內部 API(**保留既有**):`/jobs/*` 共 6 個 legacy 端點(Web UI 用) - 健康檢查:`/health`(公開) ### 1.2 技術堆疊 | 層級 | 技術 | 版本 | |------|------|------| | 執行環境 | Node.js | 18+ (alpine image, 部署用) | | Web framework | Express | 4.x | | Queue | Redis Stream + ioredis | 5.x | | 物件儲存 | MinIO(S3 compatible,AWS SDK v3) | latest | | 認證 | OAuth 2.0 + JWT(jose) | jose 5.x | | 上傳 | multer (memoryStorage) | 1.4.x | | 速率限制 | express-rate-limit | 6.x | | 安全 headers | helmet | 7.x | | 測試 | Jest | 29.x | --- ## 2. 前置需求 | 項目 | 版本 / 說明 | |------|-----------| | Node.js | 18+(fetch 原生支援、`duplex: 'half'`) | | npm | 9+ | | Docker / docker-compose(可選) | 24.x+ | | Redis | 7.x(dev / prod 都需要) | | MinIO | latest(POST /api/v1/jobs 必須啟用) | | Member Center | OAuth 2.0 Authorization Server,提供 JWKS / token endpoint | | File Access Agent | promote 階段呼叫,需支援 `PUT /files/{key}` | dev 環境若無真實 Member Center / FAA,可用 placeholder 值(見 `env.example`)。 --- ## 3. 啟動方式 ### 3.1 本機開發(純 Node) ```bash cd apps/task-scheduler cp env.example .env # 編輯 .env,至少把以下 placeholder 替換為真實值: # - MEMBER_CENTER_*(若要實際打 Member Center) # - KNERON_CONVERTER_CLIENT_SECRET # - MINIO_*(若 STORAGE_BACKEND=minio) npm install npm start # → 監聽 PORT(預設 4000) ``` ### 3.2 Docker 單體 ```bash docker build -t task-scheduler:dev apps/task-scheduler docker run --rm --env-file apps/task-scheduler/.env -p 4000:4000 task-scheduler:dev ``` ### 3.3 docker-compose(推薦) 專案根目錄已有 `docker-compose.yml`,會一併啟動 Redis、MinIO、Workers、frontend: ```bash cd /path/to/kneron_model_converter cp apps/task-scheduler/env.example .env # 或維護一份 root .env docker compose up -d --build ``` 服務埠對外: - Scheduler API:`http://localhost:4000` - Web UI:`http://localhost:3000` - MinIO Console:`http://localhost:9001` ### 3.4 Health check ```bash curl http://localhost:4000/health | jq . ``` 回應為三層 status(healthy / degraded / unhealthy)+ 各依賴狀態, 詳見 [§ 7. 監控](#7-監控)。 ### 3.5 Graceful shutdown 服務監聽 `SIGTERM` / `SIGINT`:收到後會先停掉 health background polling, 再讓 Express 自然關閉。容器 / K8s 部署時 `terminationGracePeriodSeconds` 建議至少 30 秒。 --- ## 4. 專案結構 ``` apps/task-scheduler/ ├── server.js ← entry(< 140 行;組裝 deps、啟動 listener、listen) ├── src/ │ ├── app.js ← Express app factory │ ├── config.js ← 集中讀 env,啟動時 fail-fast │ ├── redis.js ← Redis client + helpers │ ├── auth/ │ │ ├── jwks.js ← jose remote JWKS cache + jwtVerify │ │ ├── middleware.js ← requireAuth(scope) Express middleware │ │ └── oauthClient.js ← Converter as OAuth Client(client_credentials) │ ├── fileAccessAgent/ │ │ ├── client.js ← FAA HTTP client(PUT only,重試 + 401 invalidate) │ │ └── errors.js │ ├── middleware/ │ │ ├── errorHandler.js ← 統一 error 格式(v1 限定) │ │ ├── requestId.js ← X-Request-Id 透傳 / 生成 │ │ ├── perClientRateLimit.js ← per-client_id rate limiter │ │ ├── upload.js ← multer 設定 │ │ └── uploadConcurrency.js ← per-process upload semaphore(防 OOM) │ ├── routes/ │ │ ├── legacy.js ← /jobs* 6 個端點(Web UI 用) │ │ └── v1/ │ │ ├── index.js ← /api/v1 mount + 內部 errorHandler │ │ ├── jobs.js ← POST/GET /jobs, GET /jobs/:id, 預留 501 │ │ ├── promote.js ← POST /jobs/:id/promote │ │ └── validators/ │ │ └── createJob.js ← multipart fields validator │ ├── services/ │ │ ├── jobService.js ← Job CRUD + claim_active / advance / fail │ │ ├── doneListener.js ← Redis Stream 背景 listener │ │ ├── healthService.js ← /health 背景 polling cache │ │ ├── statusMapper.js ← 內部大寫 status → 對外 status + stage │ │ └── sseService.js ← SSE 推送(legacy) │ ├── storage/ │ │ ├── minio.js ← AWS SDK v3 S3 facade │ │ └── local.js ← STORAGE_BACKEND=local 模式 │ ├── redis/ │ │ └── luaScripts.js ← claim_active_job / release_active_job │ └── utils/ │ └── sanitize.js ← filename / user_id / path 安全處理 ├── docs/ │ └── openapi.yaml ← Phase 1 對外 API spec(給 visionA 等消費者) ├── tests/ ← 單元 + 整合測試(見 src/**/__tests__/) ├── package.json ├── Dockerfile ← 多層快取 + 非 root user + HEALTHCHECK ├── env.example ← 完整環境變數範本(不含真實 secret) └── README.md ← 本檔 ``` --- ## 5. 環境變數 完整清單(含預設、必填與否、說明)見 [`env.example`](./env.example)。 簡表(依分類): ### 5.1 必填(缺漏會 fail-fast、process exit code 1) | 變數 | 用途 | |------|------| | `REDIS_URL` | Redis 連線(含 password) | | `STORAGE_BACKEND` | `local` / `minio`;POST /api/v1/jobs 必須 `minio` | | `MEMBER_CENTER_ISSUER` | JWT iss 比對基準 | | `MEMBER_CENTER_JWKS_URL` | JWKS endpoint(驗 token 用) | | `MEMBER_CENTER_TOKEN_URL` | token endpoint(取 promote 用 token) | | `KNERON_CONVERTER_AUDIENCE` | 接受 JWT 的 aud | | `KNERON_CONVERTER_CLIENT_ID` | Converter 自己 OAuth client | | `KNERON_CONVERTER_CLIENT_SECRET` | **不要進 git;用 secret manager** | | `FILE_ACCESS_AGENT_BASE_URL` | promote 目標;production 強制 https | | `FILE_ACCESS_AGENT_AUDIENCE` | promote token 的 aud | `STORAGE_BACKEND=minio` 時還需:`MINIO_ENDPOINT_URL` / `MINIO_BUCKET` / `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY`。 ### 5.2 可選(有合理預設) 涵蓋: - 上傳上限(`MULTIPART_MODEL_MAX_BYTES` 預設 500MB、`MULTIPART_REF_IMAGE_MAX_BYTES` 預設 10MB、`MULTIPART_REF_IMAGES_MAX_COUNT` 預設 100) - 上傳並發(`MAX_CONCURRENT_UPLOADS` 預設 5、`UPLOAD_RETRY_AFTER_SECONDS` 預設 30) - Rate limit(`API_V1_RATE_LIMIT_WINDOW_MS` 預設 5min、`API_V1_RATE_LIMIT_MAX` 預設 300) - JWKS 行為(`JWKS_CACHE_MAX_AGE_MS`、`JWKS_COOLDOWN_MS`、`JWT_CLOCK_TOLERANCE_SEC`) - OAuth client(`OAUTH_TOKEN_REFRESH_SKEW_MS`、`OAUTH_TOKEN_TIMEOUT_MS`) - promote timeout(`PROMOTE_TIMEOUT_MS` 預設 300s) - Tenant 隔離(`CONVERTER_TENANT_ID`,空字串 = 不檢查) - Scope 命名覆寫(`CONVERTER_SCOPE_WRITE` / `CONVERTER_SCOPE_READ`) ### 5.3 安全提醒 - `.env` 已在 `.gitignore`;不要 commit - production 用 secret manager(Vault / AWS Secrets Manager / K8s Secret), 而不是把 secret 直接放進 docker-compose env - 任何含 `REPLACE-ME` 字樣或 `.invalid` TLD 的 placeholder,**部署前必須替換** --- ## 6. API 概覽 ### 6.1 Phase 1 對外 API(`/api/v1/*`) | 方法 | 路徑 | scope | 說明 | |------|------|-------|------| | POST | `/api/v1/jobs` | `converter:job.write` | 建立轉檔 job(multipart) | | GET | `/api/v1/jobs` | `converter:job.read` | Recovery 列表(user_id 必填) | | GET | `/api/v1/jobs/:id` | `converter:job.read` | 單一 job 狀態(含 ETag) | | POST | `/api/v1/jobs/:id/promote` | `converter:job.write` | 結果檔搬到 FAA | | POST | `/api/v1/jobs/:id/download-tokens` | `converter:job.read` | **Phase 2 預留**,回 501 | | DELETE | `/api/v1/jobs/:id` | `converter:job.write` | **Phase 2 預留**,回 501 | 完整規格、所有 schema、所有錯誤情境的 example:見 [`docs/openapi.yaml`](./docs/openapi.yaml)。 ### 6.2 Legacy / 內部 API(`/jobs/*`,僅內網 vhost 暴露) 對 Web UI 100% 不變更行為(T4 重構僅是「移動 + 抽象」): | 方法 | 路徑 | 說明 | |------|------|------| | POST | `/jobs` | Web UI 上傳建 job(multipart,無 user_id 概念) | | GET | `/jobs` | 列出全部 job(legacy KEYS scan) | | GET | `/jobs/:jobId` | 查單一 job | | GET | `/jobs/:jobId/events` | SSE 推送 | | GET | `/jobs/:jobId/download/:filename` | 下載結果檔 | | GET | `/queues/stats` | Redis Stream / Group 統計 | ### 6.3 健康檢查 | 方法 | 路徑 | 說明 | |------|------|------| | GET | `/health` | 公開,不需認證 | --- ## 7. Auth 流程 ### 7.1 上游消費者(visionA-backend)取 token Converter 是 OAuth 2.0 Resource Server。建議消費者用 `client_credentials` grant 從 Member Center 取得 service-to-service token: ``` POST {member-center}/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=client_credentials &client_id= &client_secret= &scope=converter:job.write converter:job.read &audience=kneron_converter_api ``` ### 7.2 Converter 端驗證 每個 `/api/v1/*` request 進入時: 1. Bearer token 驗章(`jose.createRemoteJWKSet` + `jwtVerify`) 2. `iss` / `aud` / `exp`(含 60 秒 clock skew) 3. `scope`(端點要求的 scope 必須在 token claim 內) 4. `tenant_id`(若 `CONVERTER_TENANT_ID` 非空則檢查) 5. `client_id`(用於 rate limit / log / job 隔離) 驗證失敗時: - 回 v1 標準錯誤格式(`{error: {code, message, details, request_id}}`) - **設 `Connection: close` header + `req.socket.destroy()`**:阻止 unauthorized client 繼續灌大檔。但這是 best-effort;真正的 body 上限 靠 Nginx `client_max_body_size`(部署層) ### 7.3 Converter 取 promote 用 token promote 時 Converter 切換成 OAuth Client,用 `client_credentials` 取 `files:upload.write` scope token,PUT 到 FAA。 token cache per scope,過期前 60s 主動 refresh;FAA 回 401 時自動 invalidate cache 並重試一次。 --- ## 8. 錯誤碼總表 | HTTP | code | 說明 | |------|------|------| | 400 | `validation_error` | 欄位格式錯(`details.fields[]` 列具體欄位) | | 400 | `invalid_multipart` | multipart parse 失敗、缺必要 file、副檔名不符 | | 401 | `invalid_token` | JWT 無效 / 簽章錯 / 缺 claim | | 401 | `token_expired` | JWT 過期 | | 403 | `insufficient_scope` | scope 不足(`details.required_scope` / `provided_scopes`) | | 403 | `tenant_mismatch` | tenant_id 不符 | | 404 | `job_not_found` | job 不存在或不屬於該 client(不洩漏存在性) | | 404 | `not_found` | 路徑不存在 | | 409 | `user_has_active_job` | 同 user 已有未完成 job(`details.active_job_*`) | | 409 | `job_not_ready_for_promote` | promote 時 job 非 completed | | 409 | `source_not_available` | promote 的 source stage 沒產出 | | 413 | `file_too_large` | 上傳超過大小上限(model 500MB / ref_image 10MB) | | 422 | `invalid_object_key` | promote target_object_key 格式不合法 | | 429 | `rate_limit_exceeded` | per-client rate limit | | 500 | `misconfiguration` | 伺服器設定錯(如 STORAGE_BACKEND 非 minio) | | 500 | `internal_error` | 其他未分類錯誤 | | 501 | `not_implemented` | Phase 2 預留端點 | | 502 | `storage_unavailable` | MinIO 寫入失敗 | | 502 | `file_gateway_unavailable` | FAA 不可用 / 拒絕 | | 503 | `auth_service_unavailable` | Member Center 取 token 失敗 | | 503 | `service_busy` | upload concurrency 已滿(`Retry-After` header) | response 完整 schema 見 [`docs/openapi.yaml`](./docs/openapi.yaml#components/schemas/ApiError)。 --- ## 9. 與其他服務的關係 | 服務 | 連接方式 | 用途 | 失敗影響 | |------|---------|------|---------| | Member Center | HTTPS | 驗 visionA token / 取 promote token | 新 token 無法驗(cache 內舊 token 仍可用);promote 階段失敗 | | File Access Agent | HTTPS | promote 結果檔搬到 NAS | promote 失敗,但 job 本身已 completed,可重試 | | MinIO | HTTP / HTTPS | 原始模型 / 結果檔暫存(7 天 lifecycle) | POST /jobs 直接 502,promote 也會失敗 | | Redis | TCP | Job state、active_job lock、Stream queue | 整個服務 unhealthy | | Worker(onnx / bie / nef) | Redis Stream | 跑 pipeline | Job 卡在某個 stage,TTL 7 天會自動清 | --- ## 10. 監控 ### 10.1 `/health` 的三層 status | status | HTTP | 對應狀態 | |--------|------|---------| | `healthy` | 200 | Redis / MC / FAA 都連通 | | `degraded` | 200 | Redis 連通,但 MC / FAA 任一不可達 | | `unhealthy` | 503 | Redis 斷線 | response body 同時包含 `dependencies.{redis, member_center, file_access_agent}` 細節,可給 K8s readiness / liveness probe 區分嚴重度。 ### 10.2 結構化日誌 所有 v1 路徑的 handler 都輸出 JSON log(stdout): ```json { "service": "task-scheduler", "timestamp": "2026-04-25T12:00:00.123Z", "level": "INFO", "action": "jobs.create.success", "request_id": "7c6e4f3b-...", "job_id": "550e8400-...", "user_id": "alice", "client_id": "kneron_converter_dev", "size_bytes": 204800000, "ref_images_count": 0, "duration_ms": 234 } ``` `action` 欄位採 `domain.event` 格式,便於用 jq / loki 過濾。 ### 10.3 Rate limit headers 回應自動帶: - `X-RateLimit-Limit` / `RateLimit-Limit` - `X-RateLimit-Remaining` / `RateLimit-Remaining` - 超限時:`Retry-After`(秒) --- ## 11. Phase 1 已知接受風險 > 本節為摘要,完整內容見 [`.autoflow/04-architecture/security.md`](../../.autoflow/04-architecture/security.md)。 ### 11.1 user_id 信任邊界(最重要) - `user_id` 來自 multipart form field(POST)或 query string(GET), **不**從 JWT claim derive - Converter 完全信任 visionA-backend 帶來的 user_id 是對的,**不做 user 層級 ACL** - visionA-backend 一旦被 compromise,attacker 可冒充任何 user_id **Phase 1 接受此風險的理由**: 1. visionA-backend 是內部受控系統,非 Internet-facing 2. Phase 1 重點是 pipeline 跑通;安全強化排在 Phase 2 3. HMAC / OBO 流程要 visionA / Member Center 配合,已對齊但尚未實作 **Phase 1 mitigation**: - per-client_id rate limit(300 req / 5 min) - 結構化 audit log 含 `client_id` + `user_id` - 7 天 active_job TTL(避免 lock 永久不釋放) - `user_id` 嚴格白名單(`^[A-Za-z0-9._-]{1,128}$`)擋 XSS / Redis key injection **Phase 2 候補**:HMAC-signed user_id(短期)/ OAuth Token Exchange(中期)。 ### 11.2 大檔上傳的 OOM 風險 - multer 用 `memoryStorage` — 每個並發 upload 吃 model size 大小的 heap - 5 並發 × 500MB = 2.5GB;`MAX_CONCURRENT_UPLOADS` 預設 5(4GB 容器安全) - 超過時 503 + `Retry-After`,client 主動 backoff ### 11.3 Trust boundary 與 Nginx 層 - 401/403 後 server 雖會 `socket.destroy()`,但這是 best-effort - 真正的 body 大小上限由 Nginx vhost `client_max_body_size 600M` 把關 - Nginx 雙 vhost 設定詳見 TDD §7.1(DevOps 範圍,非後端) ### 11.4 Per-process state(Phase 2 才需處理) - rate limiter / upload concurrency 都是 in-process counter - Phase 1 部署為單 instance,無問題;Phase 2 多 instance 時要改 Redis store --- ## 12. 測試 ```bash npm test # 跑所有 unit + integration test(630 tests,~4 秒) npm test -- --watch # watch 模式 npm test -- src/auth # 只跑 auth 模組的測試 ``` 測試金字塔: - 單元測試(70%):service / validator / utils / middleware - 整合測試(20%):route + middleware + Redis 模擬 / FAA mock - E2E(10%):由 Testing Agent 跑(不在本套件內) CI 用:`npm test`。 --- ## 13. 故障排除(常見場景) | 症狀 | 可能原因 | 排查 | |------|---------|------| | 啟動立刻 exit 1 | env 缺漏 | 看 `[Scheduler] Config validation failed` log;對照 `env.example` | | 401 invalid_token / token_expired | clock skew、JWKS cache 沒拿到新 kid | 檢查 server 時鐘、`MEMBER_CENTER_JWKS_URL` 可達性 | | 401 後 client 連線立刻斷 | 設計如此(`Connection: close` + `socket.destroy()`) | 正常行為,避免 client 繼續灌 body | | 409 user_has_active_job 但前一個 job 已 failed | active_job lock 沒被釋放 | 看 worker done listener 是否運作;最壞情況 7 天 TTL 會自動清 | | 502 storage_unavailable | MinIO 不可達 / 認證錯 | 檢查 `MINIO_*` env、bucket 是否存在 | | 502 file_gateway_unavailable | FAA 5xx 或 4xx 拒絕(非 401) | 看 server log `promote.faa_put_failed`,FAA 端排查 | | 503 auth_service_unavailable | Member Center token endpoint 死 / 401 兩次 | 確認 `MEMBER_CENTER_TOKEN_URL` 可達、`KNERON_CONVERTER_CLIENT_*` 對 | | 503 service_busy + Retry-After | upload concurrency 已滿 | 等 Retry-After,或調高 `MAX_CONCURRENT_UPLOADS`(注意 OOM) | | 503 unhealthy(/health) | Redis 斷線 | 檢查 `REDIS_URL` 與 Redis 服務狀態 | | GET /jobs 回 400 missing user_id | Phase 1 強制 user_id 必填 | client 端帶 user_id query string | | 大檔上傳跑到一半 5xx | Nginx `client_max_body_size` 太小 | 部署層調 `client_max_body_size 600M`(不在 backend 範圍) | 更多細節: - `.autoflow/04-architecture/TDD.md`(完整規格) - `.autoflow/04-architecture/security.md`(安全模型 / 接受風險) - `.autoflow/05-implementation/tasks-phase1.md`(任務拆分與決策紀錄) --- ## 14. 文件參照 | 文件 | 內容 | |------|------| | [`docs/openapi.yaml`](./docs/openapi.yaml) | Phase 1 對外 API spec(給 visionA-backend 等消費者 import) | | [`env.example`](./env.example) | 完整環境變數清單(含說明、預設、必填與否) | | `../../.autoflow/04-architecture/TDD.md` | 完整技術設計文件 | | `../../.autoflow/04-architecture/security.md` | 安全模型 / 接受風險 / Phase 2 候補 | | `../../.autoflow/04-architecture/design-doc.md` | 架構決策(為什麼選這些方案) | | `../../.autoflow/02-prd/PRD.md` | 產品需求 / user stories | | `../../.autoflow/05-implementation/tasks-phase1.md` | T1-T11 任務拆分與審查紀錄 | --- ## 15. License MIT