jim800121chen 1231bf0ed2 feat(visionA-backend): Phase 0.8 conversion package — 5 endpoint + 8 個內部模組
Phase 0.8 把 kneron_model_converter 的轉檔功能整合進 visionA Cloud。
visionA backend 當 streaming proxy(upload)+ delegated download token broker(download)+
ownership trust boundary,converter / FAA / MC 三方零修改。

新增 internal/conversion/ 套件(8 個檔,~10,000 行 prod+test,117+ test cases,race -count=3 全綠):

- conversion.go:Service interface 5 method、Job/PromoteResult/InitJobInput types
- errors.go:13+ sentinel errors + ErrorCode/HTTPStatus mapping,對齊 conversion.md §6
- mc_token_client.go:service-to-service token (client_credentials grant) + DCL cache
  (exp - 15s 重取,per-scope cache),IssueDelegatedDownload(MC delegated download token)
  錯誤分 idp_misconfigured (4xx) / idp_unavailable (5xx) / download_token_failed / mc_token_unavailable
- converter_client.go:對 converter scheduler 4 method(InitJob multipart streaming /
  GetJob / Promote / ListInProgressJobs),InitJob 不 retry 5xx(streaming body 無法 replay)
- faa_client.go:對 FAA GET /files/{key} server-to-server pull,Phase A retry(GET 無 body
  可 replay)對齊 §9.1 retry 矩陣,streaming io.ReadCloser 透傳避 OOM
- ownership.go:in-memory job_id → user_id map + per-user mutex 防 thundering herd lazy rebuild
  (不同 user 平行 fetch,同 user 100 caller 收斂成 1 次),visionA 重啟靠 converter
  ListInProgressJobs(user) 重建
- flow.go:Service interface 整合層(5 method 串接 converter/FAA/MC/ownership)
  - InitJob 用 io.Pipe + multipart.Reader/Writer 重組 streaming proxy(黑名單 client user_id
    + 灌入 OIDC sub)
  - DownloadRedirectURL 自動觸發 promote(spec §1 Stage 3b),用 ensurePromoted helper
  - PromoteToModels 冪等(modelStore.FindBySourceJobID 為 source-of-truth)
  - OwnershipMismatch → ErrJobNotFound 不 forbidden(§7.2 防枚舉)
  - storage / modelStore 失敗包 ErrStorageUnavailable / ErrModelStoreUnavailable
    (視為 visionA 自身 500 而非 502 gateway,SRE alarm 才打對 team)

新增 internal/api/conversion.go(5 endpoint handler + main.go wire):
- POST /api/conversion/init(multipart streaming proxy,不呼叫 c.MultipartForm())
- GET  /api/conversion/active(lazy rebuild ownership)
- GET  /api/conversion/{job_id}(poll status)
- POST /api/conversion/{job_id}/promote-to-models(FAA pull → models 三段式)
- GET  /api/conversion/{job_id}/download(server-side HTTP 302 → FAA,token 不過 frontend
  JS,仿 FAA TestSite DownloadFileDirect pattern;Cache-Control: no-store)

5 個 endpoint 全部走 OIDC AuthMiddleware;user_id 從 cookie session 灌(trust boundary),
從不接受 client multipart form / JSON / query 的 user_id。
TestAllAPIEndpointsRequire401WithoutCookie 自動覆蓋新 5 endpoint regression 防呆。

新增 cmd/api-server/conversion_e2e_test.go(4 個 e2e 場景):
- TestConversionE2E_StreamingProxy(10MB body + trust boundary regression)
- TestConversionE2E_LazyRebuildAfterRestart(visionA 重啟仍能 /active)
- TestConversionE2E_Download302Redirect(驗 302 + Location header + token 不在 body)
- TestConversionE2E_ActiveJobConflict(409 + active_job 詳情)

修改 internal/config/{config,load}.go:新增 ConversionConfig 5 欄位
(ConverterBaseURL / FAABaseURL / TenantID / ServiceClientID / ServiceClientSecret)+
Enabled() helper(雙非空判定)。
修改 cmd/api-server/main.go:條件 wire(cfg.Conversion.Enabled() 為 true 才建 client + Service;
否則 Deps.Conversion=nil,handler 自動回 501)。
修改 .env.example:新增 Phase 0.8 區塊註解。
新增 cmd/api-server/conversion_adapters.go:narrow interface adapter(接既有
internal/model.Repository / internal/storage.Store → conversion.ModelStore / Storage,避免 import cycle)。

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning / go build 成功。

對齊文件:
- .autoflow/04-architecture/adr/adr-014-conversion-integration.md
- .autoflow/04-architecture/conversion.md (TDD)
- .autoflow/04-architecture/api/api-conversion.md
- .autoflow/02-prd/features/feature-converter-integration.md
- .autoflow/03-design/wireframes/wireframe-conversion.md
- .autoflow/03-design/flows/flow-conversion.md

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 13:56:07 +08:00

468 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// FAA client — visionA-backend 對 File Access Agent 的 server-to-server HTTP client。
//
// Phase 0.8 只用 GET /files/{object_key}(給 promote-to-models 流程從 FAA pull NEF 用)。
// 其他 endpointPUT / DELETE / HEAD / metadata目前 visionA 不需要,未來再補。
//
// 設計要點:
// - 走 service tokenscope=files:download.readtoken 由注入的 MCTokenClient 提供
// - **回 streaming body**io.ReadCloser— 不 io.ReadAll避免 500MB NEF 全進 RAM
// - **Phase A retry**dial → 拿到 response header 之間的 5xx / network / timeout 失敗
// 依 §9.1 指數退避重試 max 2 次1s, 2s。一旦拿到 200 response進 Phase B
// streaming body 給 caller這層責任就結束 — body 中斷由 caller 處理(不可 replay
// 詳見下方 GetFile doc comment 的「Phase A vs Phase B retry」段。
// - 4xx → 對應 sentinel401/403 → ErrServiceClientUnauthorized404 → ErrFAAFileNotFound
// 其他 4xx → ErrFAAUnavailable避免新增更多 sentinel
//
// 與 T3 InitJob 的對比(為什麼 T3 不 retry 但 T4 GetFile retry
// - T3 InitJobmultipart **request body** 是 streamingio.Reader 來自上游 c.Body
// 一旦 http.Client.Do 開始送 request bodyio.Reader 已被消費retry 無法 rewind →
// 從第一次 attempt 起就「不可重試」。
// - T4 GetFileGET 沒有 request bodyrequest 完全 idempotentretry window 涵蓋
// dial → 拿到 response headerPhase A。Phase A 結束後200 已到response body
// 才是「不可 replay」的 streaming但那不在本層責任範圍 — 本層拿到 200 就 return *FAAFile。
//
// 安全:
// - **絕不**寫 Authorization header / service token / response body 進 log
// - object_key 過長時截斷(避免 log 膨脹FAA object_key 由 visionA 內部組,不含 user 敏感資訊)
//
// Phase 0.8 conversion (見 .autoflow/04-architecture/conversion.md §2.3 / §2.6 / §9.1)
package conversion
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net"
"net/http"
"net/url"
"strings"
"time"
)
// ==========================================================================
// 對外 type / interface
// ==========================================================================
// FAAClient 對 File Access Agent 的 server-to-server client。
//
// goroutine-safe每次呼叫獨立 *http.Request無內部 mutable statecache 由注入的 MCTokenClient 管)。
type FAAClient interface {
// GetFile 從 FAA pull 一個 objectserver-to-server用 service token
//
// scope: files:download.read
//
// 回傳 *FAAFile.Body 是 streaming bodyio.ReadCloser**caller 必須 Close**
// 不然底層 http.Response.Body 不會釋放、connection 也回不了 poolgoroutine + fd leak
// 推薦 pattern
//
// file, err := faa.GetFile(ctx, key)
// if err != nil { return err }
// defer file.Body.Close()
// _, err = io.Copy(dst, file.Body) // streaming 寫進 visionA storage
//
// 重試行為Phase A retry only對齊 §9.1
// - dial / TLS / response header 階段的 5xx / network / timeout
// 指數退避重試 max 2 次1s, 2s— GET 沒 request body 完全 idempotent可放心 retry
// - 401 / 403 / 404 / 其他 4xx不重試立即 return 對應 sentinel
// - ctx cancel / deadline立即 return ctx.Err()(即使在 retry sleep 中也立即中斷)
// - 一旦拿到 200 response進 Phase Breturn *FAAFilebody 由 caller 自己讀;
// caller 在讀 body 時遇到網路中斷不再重試streaming response 不可 replay
//
// 錯誤映射(對齊 conversion.md §6 + errors.go
// - ctx cancel/deadline → 透傳 ctx.Err不包成 sentinel
// - 401 / 403 → ErrServiceClientUnauthorized對外 idp_misconfigured/500
// - 404 → ErrFAAFileNotFound對外 faa_unavailable/502
// - 其他 4xx / 5xx exhausted / network exhausted → ErrFAAUnavailable對外 faa_unavailable/502
GetFile(ctx context.Context, objectKey string) (*FAAFile, error)
}
// FAAFile 是 GetFile 成功回傳的 streaming response。
//
// **caller 必須 Body.Close()**(即使中途 error也應 defer Close
type FAAFile struct {
// Body 是 streaming response bodycaller 用 io.Copy 等方式 streaming 消費。
Body io.ReadCloser
// ContentLength 對應 FAA response 的 Content-Length header。
// 若 FAA 走 chunked transfer 沒帶這個 header值為 -1net/http 慣例)。
ContentLength int64
// ContentType 對應 FAA response 的 Content-Type header如 "application/octet-stream")。
ContentType string
// ETag 對應 FAA response 的 ETag headerFAA 端取自 storage adapter
// 若 FAA 沒帶,為空字串。
ETag string
}
// FAAClientOpts 是 NewFAAClient 的依賴注入。
//
// HTTPClient / Now / Logger 為 optionalnil 自動填預設)— 方便 unit test 注入 fake。
type FAAClientOpts struct {
// BaseURL 是 FAA base URL不帶結尾斜線
// 範例http://192.168.0.130:5081
BaseURL string
// Tokens 是 MCTokenClient注入non-nil 必填)— 用來取 service token。
Tokens MCTokenClient
// HTTPClient 為 optionalnil 用預設(含 dial / response header timeout但無整體 timeout
// 測試會注入 httptest.Server.Client()。
//
// 為什麼預設 client 不設 Timeout
// 500MB NEF 在慢網路下 download 可能 5-10 分鐘http.Client.Timeout 是「整體 timeout」
// 涵蓋「dial + response header + body 讀完」三段,會在大檔下載中途斷線。
// 改用 transport 層的 DialTimeout + ResponseHeaderTimeout10s 各自)— 連線階段卡死才算 fail
// body streaming 階段交給 ctx.Done() 控制caller 用帶 deadline 的 ctx 即可)。
HTTPClient *http.Client
// Now 為 optionalnil 用 time.Now。測試會注入 fake clock。
Now func() time.Time
// Logger 為 optionalnil 用 slog.Default()。
Logger *slog.Logger
}
// ==========================================================================
// 內部固定常數
// ==========================================================================
const (
// scopeFAADownloadRead 對齊 FAA README §「初步 API 邊界」與 FileAccessScopes.DownloadRead。
scopeFAADownloadRead = "files:download.read"
// faaDialTimeout 是 dial 階段的 timeout連 TCP / TLS 握手)。
// 連線一直建不起來通常是路由問題10s 已足夠;超過視為 FAA 不可達。
faaDialTimeout = 10 * time.Second
// faaResponseHeaderTimeout 是「送完 request → 收到 response status 行」的 timeout。
// 這段是 server-side 處理時間FAA 找檔、auth validate10s 對小檔 metadata 階段夠寬鬆。
// 注意:這個 timeout **不涵蓋 body streaming 階段**body streaming 由 ctx 控制)。
faaResponseHeaderTimeout = 10 * time.Second
// faaMaxRetries 是 Phase A 5xx / network / timeout 的最大重試次數(不含第一次)。
// 對齊 conversion.md §9.1FAA GET /files/{key} max 2 retries1s, 2s
faaMaxRetries = 2
// faaRetryBaseDelay 是指數退避的 base1s, 2s
faaRetryBaseDelay = 1 * time.Second
// objectKeyHashLen 是 log 中 object_key 的截短後 hash 長度(前 16 hex chars
objectKeyHashLen = 16
// faaErrorBodyReadCap 是失敗 response 從 body 讀進 io.Discard 的最大量4KB
// 失敗時讀少量 body 主要是讓 keep-alive 能 reuse connection避免空 body 留在 pipe。
faaErrorBodyReadCap = 4 * 1024
)
// faaEndpointKind 是 log / 錯誤分類用的 endpoint 標記(目前只有一個)。
const faaEndpointKind = "faa_get_file"
// ==========================================================================
// 構造 + 內部實作
// ==========================================================================
// faaClient 是 FAAClient 的預設實作。
//
// 套件內 unexported structcaller 拿 interface讓未來換實作不影響 caller。
type faaClient struct {
baseURL string
tokens MCTokenClient
http *http.Client
now func() time.Time
logger *slog.Logger
}
// NewFAAClient 建立一個 FAAClient 實例。
//
// 必填BaseURL / Tokens。其他 optional。
// 注意constructor 不會驗 BaseURL 連線,第一次 GetFile 才會打網路。
func NewFAAClient(opts FAAClientOpts) FAAClient {
httpClient := opts.HTTPClient
if httpClient == nil {
httpClient = newDefaultFAAHTTPClient()
}
now := opts.Now
if now == nil {
now = time.Now
}
logger := opts.Logger
if logger == nil {
logger = slog.Default()
}
return &faaClient{
baseURL: strings.TrimRight(opts.BaseURL, "/"),
tokens: opts.Tokens,
http: httpClient,
now: now,
logger: logger,
}
}
// newDefaultFAAHTTPClient 建一個適合 streaming download 的預設 http.Client。
//
// 為什麼自訂 transport
// - http.Client.Timeout 不適用大檔下載(會中斷 body streaming
// - 需要分別控制 dial / response header timeoutbody streaming 不限制(由 ctx 控)
//
// transport 其餘參數沿用 net/http DefaultTransport 的合理預設MaxIdleConns 等)。
func newDefaultFAAHTTPClient() *http.Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: faaDialTimeout,
KeepAlive: 30 * time.Second,
}).DialContext,
ResponseHeaderTimeout: faaResponseHeaderTimeout,
// 沿用 DefaultTransport 的合理預設
MaxIdleConns: 100,
MaxIdleConnsPerHost: 10,
IdleConnTimeout: 90 * time.Second,
TLSHandshakeTimeout: 10 * time.Second,
ExpectContinueTimeout: 1 * time.Second,
}
return &http.Client{
Transport: transport,
// **不設 Timeout** — body streaming 階段由 ctx 控制
}
}
// ==========================================================================
// GetFile — Phase A retryPhase B 不 retry 的 streaming pull
// ==========================================================================
// GetFile 實作 FAAClient.GetFile。
//
// 流程:
// 1. 取 service token透過 MCTokenClient其錯誤透傳不重新分類
// 2. 組 URL + 建 request
// 3. doWithRetrymax (1 + faaMaxRetries) attempts每 attempt 重新 c.http.Do
// - 拿到 200直接 return *FAAFile不 close body
// - 拿到 4xxclose body 後依 status mapping 對應 sentinel不 retry
// - 拿到 5xxclose body等 backoff 後 retry
// - network / dial / responseHeader timeout等 backoff 後 retry
// - ctx cancel / deadline立即 return ctx.Err()
func (c *faaClient) GetFile(ctx context.Context, objectKey string) (*FAAFile, error) {
if objectKey == "" {
return nil, fmt.Errorf("conversion/faa_client: object_key is required")
}
keyHash := hashObjectKey(objectKey)
// 1. 取 service token
// ServiceToken 內部已依 §6 mapping 失敗ErrServiceClientUnauthorized / ErrIDPMisconfigured /
// ErrIDPUnavailable— 這裡用 fmt.Errorf("%w") 透傳,不再二次包裝(避免錯誤碼被「升級」
// 成 ErrFAAUnavailable 而失去原本的 i18n 區分 idp_misconfig vs idp_down
token, err := c.tokens.ServiceToken(ctx, scopeFAADownloadRead)
if err != nil {
return nil, fmt.Errorf("conversion: get service token for faa download: %w", err)
}
// 2. 組 endpoint。注意 FAA 的 object_key 可能含路徑分隔符(如 "tenant/jobs/abc/output.nef")—
// 用 ResolveReference 處理net/http 內部會做 path escape避免 "../" 等問題。
endpoint, err := c.buildFileURL(objectKey)
if err != nil {
return nil, fmt.Errorf("%w: build faa url: %v", ErrFAAUnavailable, err)
}
// 3. 進 retry loopPhase A only
return c.doWithRetry(ctx, keyHash, endpoint, token)
}
// doWithRetry 是 GetFile 的 Phase A retry 執行器。
//
// 與 mc_token_client.doWithRetry / converter_client.doWithRetry 結構類似,但有以下差異:
// - 成功路徑回傳 *FAAFile含未 close 的 streaming body不是 []byte
// - 沒有「每次 attempt 重新建 request」需求 — GET 沒 bodyrequest 物件可重用,
// 但為了讓 ctx-aware 行為一致ctx cancel 後不重用舊 request這裡每次都新建一個
// - reqBuilder 不接 token 參數 — token 在 GetFile 取一次retry 期間沿用同一 token
// retry window 短max 1+2+3=6stoken 不會在這段期間過期)
//
// 為什麼 retry 期間不重新取 token
// - 簡化:避免 token 取失敗 vs HTTP 失敗 兩種錯誤交織的處理
// - 安全401 在這層被分類為「不可 retry」不會走到「token expired 中途要 refresh」場景
// - 效能cache hit 情境下成本低但仍多一次 mutex6s window 內 token 不會 expire
func (c *faaClient) doWithRetry(
ctx context.Context,
keyHash, endpoint, token string,
) (*FAAFile, error) {
var lastErr error
for attempt := 0; attempt <= faaMaxRetries; attempt++ {
// retry 前等待退避ctx cancel 立即中斷
if attempt > 0 {
select {
case <-ctx.Done():
// ctx cancel/deadline → 立即 return不 retry不包成 sentinel
return nil, ctx.Err()
case <-time.After(faaRetryBackoff(attempt)):
}
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
// 建 request 失敗極罕見URL parse 異常)— 不可 retry
return nil, fmt.Errorf("%w: build faa request: %v", ErrFAAUnavailable, err)
}
req.Header.Set("Accept", "application/octet-stream")
req.Header.Set("Authorization", "Bearer "+token)
file, classifiedErr, retryable := c.doOnce(req, keyHash, attempt)
if classifiedErr == nil {
// 成功 — file 含未 close 的 body由 caller 接手
return file, nil
}
lastErr = classifiedErr
if !retryable {
// 4xx / 401-403 / 404 / ctx cancel直接 return不再 retry
return nil, classifiedErr
}
// retryable 5xx / network / timeout繼續下一輪
}
// 用完 retry 額度
c.logger.Warn("conversion.faa.retry_exhausted",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempts", faaMaxRetries+1))
return nil, lastErr
}
// doOnce 執行一次 Phase A發 request → 等 response header → 分類結果。
//
// 回傳:
// - 成功2xxfile != nil含未 close 的 streaming body, classifiedErr=nil, retryable=false
// - 失敗file=nil, classifiedErr 為 sentinel-wrapped error, retryable 表示是否該重試
//
// 重要:成功時 callerdoWithRetry會直接把 file 透傳出去 — 這層**不 close body**。
// 失敗時這層**會 close body**(讀少量讓 keep-alive reuse connection
func (c *faaClient) doOnce(
req *http.Request,
keyHash string,
attempt int,
) (file *FAAFile, err error, retryable bool) {
startedAt := c.now()
res, doErr := c.http.Do(req)
duration := c.now().Sub(startedAt)
if doErr != nil {
// network / dial / response header timeout / ctx cancel
if errors.Is(doErr, context.Canceled) || errors.Is(doErr, context.DeadlineExceeded) {
c.logger.Warn("conversion.faa.ctx_cancelled",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
return nil, doErr, false
}
c.logger.Warn("conversion.faa.network_error",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration),
// err.Error() 不會含 secrethttp.Client 錯誤訊息只有 URL + 連線層 errno
// 但仍 truncate 防 log 爆量
slog.String("err", truncate(doErr.Error(), 200)))
return nil, fmt.Errorf("%w: faa network error: %v", ErrFAAUnavailable, doErr), true
}
// 成功2xx直接把 res.Body 透傳給 caller streaming 消費 — **不在這裡 close**
// 注意:成功路徑沒 defer res.Body.Close() — body 的所有權交給 *FAAFile.Body。
if res.StatusCode >= 200 && res.StatusCode < 300 {
c.logger.Info("conversion.faa.get_success",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Int64("content_length", res.ContentLength),
slog.Duration("duration", duration))
return &FAAFile{
Body: res.Body, // caller 責任 Close
ContentLength: res.ContentLength,
ContentType: res.Header.Get("Content-Type"),
ETag: res.Header.Get("ETag"),
}, nil, false
}
// 失敗(非 2xx讀少量 body 做 log避免 5xx 帶大 body 爆 log然後 close
// 讀進 io.Discard 而不是真的存下來:
// - 不寫進 logFAA 錯誤 body 可能含 requestId / 路徑等內部資訊)
// - 只是讓 keep-alive 能 reuse connectionread-to-EOF or close
defer res.Body.Close()
_, _ = io.CopyN(io.Discard, res.Body, faaErrorBodyReadCap)
c.logger.Warn("conversion.faa.endpoint_error",
slog.String("endpoint", faaEndpointKind),
slog.String("object_key_hash", keyHash),
slog.Int("status", res.StatusCode),
slog.Int("attempt", attempt+1),
slog.Duration("duration", duration))
mappedErr, isRetryable := c.mapGetFileError(res.StatusCode)
return nil, mappedErr, isRetryable
}
// mapGetFileError 把 FAA `GET /files/{key}` 的非 2xx 對應到 sentinel + 是否 retryable。
//
// 對齊 FAA Program.cs MapGet("/files/{**objectKey}") 的失敗回應:
// - 401 invalid_token / validation_unavailable → ErrServiceClientUnauthorized不 retry — secret 設定錯)
// - 403 tenant_mismatch / object_key_mismatch / method_mismatch → ErrServiceClientUnauthorized不 retry
// - 404 file_not_found → ErrFAAFileNotFound不 retry — object 不存在)
// - 400 invalid_object_key → ErrFAAUnavailable不 retry — visionA 端 object_key 命名 bug
// - 其他 4xx → ErrFAAUnavailable不 retry
// - 5xx → ErrFAAUnavailable**可 retry**FAA / 下游 storage 暫時失常)
func (c *faaClient) mapGetFileError(status int) (err error, retryable bool) {
switch {
case status == http.StatusUnauthorized || status == http.StatusForbidden:
return fmt.Errorf("%w: faa get file %d", ErrServiceClientUnauthorized, status), false
case status == http.StatusNotFound:
return fmt.Errorf("%w: faa get file %d", ErrFAAFileNotFound, status), false
case status >= 400 && status < 500:
// 400 / 其他 4xx不可 retry
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), false
default:
// 5xx可 retry
return fmt.Errorf("%w: faa get file %d", ErrFAAUnavailable, status), true
}
}
// faaRetryBackoff 回傳第 n 次 retryn 從 1 開始)的等待時間。
// 1 → 1s, 2 → 2s對齊 conversion.md §9.1
//
// 不加 jitter — Phase 0.8 同時打 FAA 的 caller 數量有限promote-to-models 流程是
// 序列式 per-job 觸發併發競爭機率低jitter 的邊際效益低。
func faaRetryBackoff(attempt int) time.Duration {
if attempt < 1 {
return faaRetryBaseDelay
}
return faaRetryBaseDelay * time.Duration(attempt)
}
// buildFileURL 用 url.Parse + ResolveReference 組 GET /files/{objectKey} 的完整 URL。
//
// 為什麼用 ResolveReference 而不是 string concat
// - object_key 可能含路徑分隔符("tenant/jobs/abc/output.nef"
// - 直接 concat 容易踩 trailing-slash / encoding 雷
// - net/url 會做必要的 percent-escape保留 '/' 為 path separator
func (c *faaClient) buildFileURL(objectKey string) (string, error) {
base, err := url.Parse(c.baseURL)
if err != nil {
return "", fmt.Errorf("parse base url: %w", err)
}
// 用 url.URL{Path: ...} 避免手動 escapenet/url 會處理 path encoding。
// 注意base.Path 可能為空或結尾帶 "/"ResolveReference 會處理。
ref := &url.URL{Path: "/files/" + objectKey}
return base.ResolveReference(ref).String(), nil
}
// hashObjectKey 把 object_key 算 SHA-256 後取前 16 hex chars當 log 用的穩定 hash。
//
// 為什麼不直接 log object_key
// - object_key 可能含路徑("tenant/jobs/uuid/output.nef")— 過長
// - 目前 visionA 的 object_key 不直接含 user 敏感資訊,但保險起見統一 hash
// - 16 chars hex64-bit對 visionA 內部 job 數量來說碰撞機率極低,足以追蹤單一 request
func hashObjectKey(objectKey string) string {
sum := sha256.Sum256([]byte(objectKey))
return hex.EncodeToString(sum[:])[:objectKeyHashLen]
}