Files
arcrun-collector/manifest.go
T
Leo fdb7a67484 feat(ingest-hash-trigger): collector R2 content-addressed 原稿上傳(SDD task 3)
- collector upload 子命令=scan+把 added/modified 原稿傳 R2(CF REST API
  Bearer token,key=raw/<sha256hex>,對齊 collector-trigger.v1 的 r2_key)
- 冪等:存在檢查命中=skipped_exists 不 PUT;CF API objects 端點不支援
  HEAD(live 實測 405)→ 改 GET+Range: bytes=0-0
- 完整性:上傳前重算 hash 核對 key,不符=failed 不上傳
- 失敗語意:failed → exit 1,ingested_hash 不動=下輪自動重試;
  回寫鉤子 Manifest.MarkIngested 留給 task 4
- 設定只走環境變數 CF_ACCOUNT_ID/CF_API_TOKEN/R2_BUCKET,不落 repo
- go test 14/14 綠(httptest mock 對齊真 API:HEAD 405);live e2e 全通
  (arcrun-rag-raw-demo:真上傳→重傳 no-op→下載 diff 一致+sha256==key)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 21:27:42 +08:00

119 lines
3.6 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.
// manifest.go — collector 本機 manifest 讀寫(SDD ingest-hash-trigger design §2)。
// manifest 只由 collector 寫入,雲端不得回寫(design §6-4)。
package main
import (
"crypto/rand"
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
)
// ManifestEntry 是單一檔案在 manifest 裡的狀態。
// - mtime 只決定「要不要重算 hash」(fast-path),絕不作為內容變更判準。
// - ingested_hash 與 content_hash 分開存=可表達「已變更但尚未成功 ingest」;
// 上傳失敗不更新 ingested_hash,天然可重試。本階段(不接網路)永不寫 ingested_hash
// 留給之後的上傳/webhook task 在成功後回寫。
type ManifestEntry struct {
ContentHash string `json:"content_hash"`
Size int64 `json:"size"`
Mtime int64 `json:"mtime"`
IngestedHash string `json:"ingested_hash,omitempty"`
IngestedAt int64 `json:"ingested_at,omitempty"`
}
// Manifest 對應一個被勾選的資料夾。
type Manifest struct {
FolderID string `json:"folder_id"`
Root string `json:"root"`
Entries map[string]*ManifestEntry `json:"entries"`
}
// newUUID 產生 RFC 4122 v4 UUID(純 stdlib)。
func newUUID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", err
}
b[6] = (b[6] & 0x0f) | 0x40 // version 4
b[8] = (b[8] & 0x3f) | 0x80 // variant 10
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:16]), nil
}
// LoadManifest 讀 manifest 檔;不存在=新資料夾,生成 folder_id。
func LoadManifest(path, root string) (*Manifest, error) {
data, err := os.ReadFile(path)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
id, uerr := newUUID()
if uerr != nil {
return nil, uerr
}
return &Manifest{FolderID: id, Root: root, Entries: map[string]*ManifestEntry{}}, nil
}
return nil, err
}
var m Manifest
if err := json.Unmarshal(data, &m); err != nil {
return nil, fmt.Errorf("manifest %s 解析失敗(不覆寫、直接報錯): %w", path, err)
}
if m.Entries == nil {
m.Entries = map[string]*ManifestEntry{}
}
if m.FolderID == "" {
id, uerr := newUUID()
if uerr != nil {
return nil, uerr
}
m.FolderID = id
}
if root != "" {
m.Root = root
}
return &m, nil
}
// MarkIngested 是「整條 ingest 鏈成功」後的回寫鉤子(design §2):把該路徑的
// ingested_hash 接上當時送出的 source_hash。**R2 上傳成功不呼叫它**——上傳只是鏈的
// 第一環,要等 task 4named-webhook → ingest workflow)確認成功才回寫;在那之前
// 同檔每輪重發 added/modified=設計內重試,R2 端靠 HEAD no-op 天然冪等、零浪費。
// 回傳 false=路徑已不在 manifest(例如回報前檔案又被改名/刪除),呼叫端自行決定忽略或告警。
func (m *Manifest) MarkIngested(path, sourceHash string, at int64) bool {
e, ok := m.Entries[path]
if !ok {
return false
}
e.IngestedHash = sourceHash
e.IngestedAt = at
return true
}
// Save 原子寫入(temp + rename),避免掃描中斷留半個 JSON。
func (m *Manifest) Save(path string) error {
data, err := json.MarshalIndent(m, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(dir, ".manifest-*.tmp")
if err != nil {
return err
}
tmpName := tmp.Name()
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
os.Remove(tmpName)
return err
}
if err := tmp.Close(); err != nil {
os.Remove(tmpName)
return err
}
return os.Rename(tmpName, path)
}