Skip to content

Commit 25d299b

Browse files
committed
feat(people): protoCache 全量重建改为分批可暂停执行
将原单次 buildClustProtoCache 全量重建重构为分批 full rebuild,降低 NAS(22万+ person)单次峰值负载,并支持 foreground active 时暂停/恢复: - 新增 protoCacheRebuildJob 状态机(running/paused/completed/failed):runBatchedFullRebuild 分页拉取全部 person ID(collectAllPersonIDsForRebuild + FaceRepository.ListAssignedPersonIDsPaged),按批构建原型(buildClustProtoCacheBatch,每批 200)累积到 staging,全部完成后原子切换 protoCache,避免中途半成品可见 - 批次间根据 BackgroundTaskCoordinator.LoadSnapshot 动态让行(空闲 250ms / CPU≥60·IO≥10·内存≥80 时 2s);foreground active 时暂停 job 保留 cursor,worker loop 在前台空闲后继续推进剩余批次 - 重建期间立即对活动缓存 applyTombstonesToCache,避免已删除/合并 person 在重建过程中仍作为匹配目标 - 新增 ProtoCacheRebuildStatusResponse 状态字段(generation/state/cursor/total/batches/pause_reason/cold_building),peopleService.ProtoCacheRebuildStatus 经 rebuildSnapshot 加锁读取,后台状态 API(/background/status)暴露 rebuild 进度与冷启动标识 - SQLite busy/locked 上报 ReportDBBusy 进入冷却;失败丢弃 staging 保留旧缓存;stop 时取消进行中 job - 补充 people_clustering_coordinator / face_repo / background_handler 单元测试覆盖分批、暂停恢复、tombstone、分页查询等路径
1 parent 4e164b0 commit 25d299b

11 files changed

Lines changed: 1272 additions & 80 deletions

backend/internal/api/v1/handler/background_handler.go

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,22 @@ import (
1111

1212
// BackgroundHandler 暴露后台任务治理的只读状态。
1313
type BackgroundHandler struct {
14-
coordinator *service.BackgroundTaskCoordinator
15-
loadSampler *service.BackgroundLoadSampler
14+
coordinator *service.BackgroundTaskCoordinator
15+
loadSampler *service.BackgroundLoadSampler
16+
rebuildStatus func() *model.ProtoCacheRebuildStatusResponse
1617
}
1718

1819
// NewBackgroundHandler 构造后台状态处理器。coordinator 可能为 nil(未注入时返回空快照),
19-
// 不应阻塞 API。
20-
func NewBackgroundHandler(coordinator *service.BackgroundTaskCoordinator, loadSampler *service.BackgroundLoadSampler) *BackgroundHandler {
21-
return &BackgroundHandler{
20+
// 不应阻塞 API。rebuildStatus 可选,用于附加 protoCache rebuild 进度快照(nil 时省略)。
21+
func NewBackgroundHandler(coordinator *service.BackgroundTaskCoordinator, loadSampler *service.BackgroundLoadSampler, rebuildStatus ...func() *model.ProtoCacheRebuildStatusResponse) *BackgroundHandler {
22+
h := &BackgroundHandler{
2223
coordinator: coordinator,
2324
loadSampler: loadSampler,
2425
}
26+
if len(rebuildStatus) > 0 {
27+
h.rebuildStatus = rebuildStatus[0]
28+
}
29+
return h
2530
}
2631

2732
// GetStatus 返回后台任务治理的只读快照。
@@ -33,6 +38,9 @@ func NewBackgroundHandler(coordinator *service.BackgroundTaskCoordinator, loadSa
3338
// @Router /api/v1/background/status [get]
3439
func (h *BackgroundHandler) GetStatus(c *gin.Context) {
3540
resp := buildBackgroundStatusResponse(h.coordinator, h.loadSampler)
41+
if h.rebuildStatus != nil {
42+
resp.ProtoCacheRebuild = h.rebuildStatus()
43+
}
3644
c.JSON(http.StatusOK, model.Response{
3745
Success: true,
3846
Message: "后台任务状态",
@@ -76,7 +84,7 @@ func buildBackgroundStatusResponse(coord *service.BackgroundTaskCoordinator, sam
7684
CPUPauseThreshold: status.CPUPauseThreshold,
7785
IOWaitPauseThreshold: status.IOWaitPauseThreshold,
7886
MemoryPauseThreshold: status.MemoryPauseThreshold,
79-
DBLockedCooldownMs: status.DBLockedCooldownMs,
87+
DBLockedCooldownMs: status.DBLockedCooldownMs,
8088
}
8189
for _, r := range status.Running {
8290
resp.Running = append(resp.Running, model.BackgroundTaskRuntimeResponse{

backend/internal/api/v1/handler/background_handler_test.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,4 +64,38 @@ func TestBackgroundHandler_GetStatus_NilCoordinatorNoPanic(t *testing.T) {
6464
assert.True(t, status.AutoTasksEnabled)
6565
// 负载字段为 unknown(-1)。
6666
assert.Equal(t, -1.0, status.Load.CPUUserPct)
67+
// 无 rebuild 时省略 proto_cache_rebuild 字段(向后兼容)。
68+
assert.Nil(t, status.ProtoCacheRebuild, "proto_cache_rebuild should be omitted when no rebuild")
69+
}
70+
71+
// TestBackgroundHandler_GetStatus_ProtoCacheRebuildSnapshot 验证注入 rebuildStatus 回调时,
72+
// 响应携带 protoCache rebuild 进度快照(含 cold_building 区分冷启动)。
73+
func TestBackgroundHandler_GetStatus_ProtoCacheRebuildSnapshot(t *testing.T) {
74+
coord := service.NewBackgroundTaskCoordinator()
75+
coord.SetBackgroundConfig(true, 70, 15, 85, nil, 120*time.Second)
76+
rebuildStatus := func() *model.ProtoCacheRebuildStatusResponse {
77+
return &model.ProtoCacheRebuildStatusResponse{
78+
Generation: 42,
79+
State: "running",
80+
ColdBuilding: true,
81+
Cursor: 600,
82+
Total: 2200,
83+
Batches: 3,
84+
}
85+
}
86+
h := NewBackgroundHandler(coord, nil, rebuildStatus)
87+
rec := performJSONRequest(t, http.MethodGet, "/api/v1/background/status", nil, nil, h.GetStatus)
88+
assert.Equal(t, http.StatusOK, rec.Code)
89+
90+
resp := decodeAPIResponse(t, rec)
91+
assert.True(t, resp.Success)
92+
93+
var status model.BackgroundTaskStatusResponse
94+
require.NoError(t, json.Unmarshal(toJSON(resp.Data), &status))
95+
require.NotNil(t, status.ProtoCacheRebuild, "proto_cache_rebuild should be present when rebuildStatus set")
96+
assert.Equal(t, uint64(42), status.ProtoCacheRebuild.Generation)
97+
assert.Equal(t, "running", status.ProtoCacheRebuild.State)
98+
assert.True(t, status.ProtoCacheRebuild.ColdBuilding, "cold_building should reflect cold-start build")
99+
assert.Equal(t, 600, status.ProtoCacheRebuild.Cursor)
100+
assert.Equal(t, 2200, status.ProtoCacheRebuild.Total)
67101
}

backend/internal/api/v1/handler/handler.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ func NewHandlers(db *gorm.DB, services *service.Services, repos *repository.Repo
4242
Auth: NewAuthHandler(services.Auth),
4343
Analyzer: NewAnalyzerHandler(services.Photo, services.Analysis, services.AnalysisRuntime),
4444
Event: NewEventHandler(services.EventClustering, repos.Event, db),
45-
Background: NewBackgroundHandler(services.BackgroundCoordinator, services.BackgroundLoadSampler),
45+
Background: NewBackgroundHandler(services.BackgroundCoordinator, services.BackgroundLoadSampler, services.ProtoCacheRebuildStatus),
4646
}
4747

4848
// AI Handler - 即使 AI 服务未配置也创建,以便配置变更后动态更新

backend/internal/model/background_task.go

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,26 @@ type BackgroundTaskStatusResponse struct {
2222
Load BackgroundLoadSnapshotResponse `json:"load"`
2323
// Thresholds 是 advisory 资源背压阈值(0 表示禁用)。
2424
Thresholds BackgroundThresholdsResponse `json:"thresholds"`
25+
// ProtoCacheRebuild 是 protoCache 分批 full rebuild 的进度快照。nil/省略表示当前没有
26+
// rebuild 在进行(向后兼容:旧客户端忽略该字段即可)。cold_building 标识冷启动构建中。
27+
ProtoCacheRebuild *ProtoCacheRebuildStatusResponse `json:"proto_cache_rebuild,omitempty"`
2528
// CapturedAt 是快照采集时间(RFC3339)。
2629
CapturedAt string `json:"captured_at"`
2730
}
2831

32+
// ProtoCacheRebuildStatusResponse 描述一次 protoCache 分批 full rebuild 的只读进度快照。
33+
// 所有字段向后兼容。state 取值:idle/running/paused/completed/failed;cold_building 为
34+
// 独立布尔,当无可用旧缓存且 rebuild 进行中时为 true,用于前端区分冷启动与普通后台 refresh。
35+
type ProtoCacheRebuildStatusResponse struct {
36+
Generation uint64 `json:"generation"`
37+
State string `json:"state"`
38+
ColdBuilding bool `json:"cold_building"`
39+
Cursor int `json:"cursor"`
40+
Total int `json:"total"`
41+
Batches int `json:"batches"`
42+
PauseReason string `json:"pause_reason,omitempty"`
43+
}
44+
2945
// BackgroundTaskRuntimeResponse 描述一个正在运行的后台任务(脱敏,仅 class/dedupe/priority/started_at)。
3046
type BackgroundTaskRuntimeResponse struct {
3147
Class string `json:"class"`
@@ -54,7 +70,7 @@ type BackgroundThresholdsResponse struct {
5470
CPUPauseThreshold float64 `json:"cpu_pause_threshold"`
5571
IOWaitPauseThreshold float64 `json:"iowait_pause_threshold"`
5672
MemoryPauseThreshold float64 `json:"memory_pause_threshold"`
57-
DBLockedCooldownMs int64 `json:"db_locked_cooldown_ms"`
73+
DBLockedCooldownMs int64 `json:"db_locked_cooldown_ms"`
5874
}
5975

6076
// FormatBackgroundTimeRFC3339 格式化时间为 RFC3339 字符串;零值返回空串。

backend/internal/repository/face_repo.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ type FaceRepository interface {
4242
ListByIDs(ids []uint) ([]*model.Face, error)
4343
ListAssigned() ([]*model.Face, error)
4444
ListAssignedPersonIDs() ([]uint, error)
45+
ListAssignedPersonIDsPaged(offset, limit int) ([]uint, error)
4546
ListPending(limit int) ([]*model.Face, error)
4647
GetPendingStats() (*PendingFaceStats, error)
4748
ListPrototypeEmbeddings(personIDs []uint, perPerson int) ([]*model.Face, error)
@@ -186,6 +187,25 @@ func (r *faceRepository) ListAssignedPersonIDs() ([]uint, error) {
186187
return ids, err
187188
}
188189

190+
// ListAssignedPersonIDsPaged returns a page of distinct assigned person IDs
191+
// ordered by person_id ascending. offset is 0-based; limit is the page size.
192+
// Designed for batched protoCache rebuild to avoid loading all person IDs in
193+
// a single query on large datasets (NAS: 220K+ rows).
194+
func (r *faceRepository) ListAssignedPersonIDsPaged(offset, limit int) ([]uint, error) {
195+
if limit <= 0 {
196+
return nil, nil
197+
}
198+
var ids []uint
199+
err := r.db.Model(&model.Face{}).
200+
Where("person_id IS NOT NULL").
201+
Distinct("person_id").
202+
Order("person_id ASC").
203+
Offset(offset).
204+
Limit(limit).
205+
Pluck("person_id", &ids).Error
206+
return ids, err
207+
}
208+
189209
func (r *faceRepository) ListPending(limit int) ([]*model.Face, error) {
190210
var faces []*model.Face
191211
// 退避策略:根据 retry_count 计算最小重试间隔

backend/internal/repository/face_repo_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package repository
22

33
import (
4+
"encoding/json"
45
"sync/atomic"
56
"testing"
67
"time"
@@ -579,3 +580,100 @@ func TestFaceRepository_ListPersonIDsSharingPhotos_NoNPlusOne(t *testing.T) {
579580
assert.Less(t, atomic.LoadInt32(qcount), int32(600), "no per-candidate N+1; chunked and bounded")
580581
assert.Greater(t, atomic.LoadInt32(qcount), int32(0))
581582
}
583+
584+
func TestFaceRepository_ListAssignedPersonIDsPaged(t *testing.T) {
585+
db := setupTestDB(t)
586+
defer teardownTestDB(db)
587+
faceRepo := NewFaceRepository(db)
588+
personRepo := NewPersonRepository(db)
589+
590+
// Create 5 persons with faces assigned.
591+
personIDs := make([]uint, 5)
592+
for i := 0; i < 5; i++ {
593+
p := &model.Person{Category: model.PersonCategoryFriend}
594+
require.NoError(t, personRepo.Create(p))
595+
personIDs[i] = p.ID
596+
require.NoError(t, faceRepo.Create(&model.Face{
597+
PhotoID: uint(i + 1),
598+
PersonID: &p.ID,
599+
BBoxX: 0.1, BBoxY: 0.1, BBoxWidth: 0.2, BBoxHeight: 0.2,
600+
Confidence: 0.9, QualityScore: 0.8,
601+
}))
602+
}
603+
// Also create an unassigned face — should not appear.
604+
require.NoError(t, faceRepo.Create(&model.Face{PhotoID: 99, BBoxX: 0.1, BBoxY: 0.1, BBoxWidth: 0.2, BBoxHeight: 0.2}))
605+
606+
// Page 1: offset=0, limit=3 → first 3 person IDs (ascending).
607+
page1, err := faceRepo.ListAssignedPersonIDsPaged(0, 3)
608+
require.NoError(t, err)
609+
assert.Len(t, page1, 3)
610+
assert.Equal(t, personIDs[0], page1[0])
611+
assert.Equal(t, personIDs[1], page1[1])
612+
assert.Equal(t, personIDs[2], page1[2])
613+
614+
// Page 2: offset=3, limit=3 → last 2 person IDs.
615+
page2, err := faceRepo.ListAssignedPersonIDsPaged(3, 3)
616+
require.NoError(t, err)
617+
assert.Len(t, page2, 2)
618+
assert.Equal(t, personIDs[3], page2[0])
619+
assert.Equal(t, personIDs[4], page2[1])
620+
621+
// Page 3: offset=5, limit=3 → empty.
622+
page3, err := faceRepo.ListAssignedPersonIDsPaged(5, 3)
623+
require.NoError(t, err)
624+
assert.Empty(t, page3)
625+
626+
// limit=0 → empty (no error).
627+
empty, err := faceRepo.ListAssignedPersonIDsPaged(0, 0)
628+
require.NoError(t, err)
629+
assert.Empty(t, empty)
630+
}
631+
632+
func TestFaceRepository_ListPrototypeEmbeddings_Batched(t *testing.T) {
633+
db := setupTestDB(t)
634+
defer teardownTestDB(db)
635+
faceRepo := NewFaceRepository(db)
636+
personRepo := NewPersonRepository(db)
637+
638+
// Create 2 persons, each with 3 faces (different quality).
639+
emb := encodeFloat32(t, []float32{1.0, 0.0, 0.0})
640+
for pid := 1; pid <= 2; pid++ {
641+
p := &model.Person{Category: model.PersonCategoryFriend}
642+
require.NoError(t, personRepo.Create(p))
643+
for f := 0; f < 3; f++ {
644+
require.NoError(t, faceRepo.Create(&model.Face{
645+
PhotoID: uint(pid*10 + f),
646+
PersonID: &p.ID,
647+
BBoxX: 0.1, BBoxY: 0.1, BBoxWidth: 0.2, BBoxHeight: 0.2,
648+
Confidence: 0.9,
649+
QualityScore: float64(3 - f), // 3, 2, 1
650+
Embedding: emb,
651+
}))
652+
}
653+
}
654+
655+
personIDs, err := faceRepo.ListAssignedPersonIDsPaged(0, 10)
656+
require.NoError(t, err)
657+
assert.Len(t, personIDs, 2)
658+
659+
// Request top 2 per person → 4 faces total.
660+
faces, err := faceRepo.ListPrototypeEmbeddings(personIDs, 2)
661+
require.NoError(t, err)
662+
assert.Len(t, faces, 4) // 2 persons × 2 per person
663+
664+
// Verify each person got 2 faces (highest quality first).
665+
byPerson := make(map[uint]int)
666+
for _, f := range faces {
667+
byPerson[*f.PersonID]++
668+
}
669+
for _, count := range byPerson {
670+
assert.Equal(t, 2, count)
671+
}
672+
}
673+
674+
func encodeFloat32(t *testing.T, vals []float32) []byte {
675+
t.Helper()
676+
payload, err := json.Marshal(vals)
677+
require.NoError(t, err)
678+
return payload
679+
}

backend/internal/service/background_task_coordinator.go

Lines changed: 29 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -44,24 +44,24 @@ const (
4444
// - MaxRuntime:预留字段,第一波仅记录到 decision,不强制 kill(执行预算由各 worker
4545
// 自身的 checkpoint 保证)。
4646
type BackgroundTaskRequest struct {
47-
Class BackgroundTaskClass
48-
Priority BackgroundTaskPriority
49-
DedupeKey string
47+
Class BackgroundTaskClass
48+
Priority BackgroundTaskPriority
49+
DedupeKey string
5050
MaxRuntime time.Duration
5151
}
5252

5353
// BackgroundDecisionReason 是准入决策的原因码,用于可观测性与状态 API。
5454
type BackgroundDecisionReason string
5555

5656
const (
57-
BackgroundDecisionAllowed BackgroundDecisionReason = "allowed"
58-
BackgroundDecisionCoalesced BackgroundDecisionReason = "coalesced"
59-
BackgroundDecisionForeground BackgroundDecisionReason = "foreground_active"
60-
BackgroundDecisionCooldown BackgroundDecisionReason = "cooldown"
61-
BackgroundDecisionAlreadyRunning BackgroundDecisionReason = "already_running"
62-
BackgroundDecisionCPUHigh BackgroundDecisionReason = "cpu_high"
63-
BackgroundDecisionIOWaitHigh BackgroundDecisionReason = "iowait_high"
64-
BackgroundDecisionMemoryHigh BackgroundDecisionReason = "memory_high"
57+
BackgroundDecisionAllowed BackgroundDecisionReason = "allowed"
58+
BackgroundDecisionCoalesced BackgroundDecisionReason = "coalesced"
59+
BackgroundDecisionForeground BackgroundDecisionReason = "foreground_active"
60+
BackgroundDecisionCooldown BackgroundDecisionReason = "cooldown"
61+
BackgroundDecisionAlreadyRunning BackgroundDecisionReason = "already_running"
62+
BackgroundDecisionCPUHigh BackgroundDecisionReason = "cpu_high"
63+
BackgroundDecisionIOWaitHigh BackgroundDecisionReason = "iowait_high"
64+
BackgroundDecisionMemoryHigh BackgroundDecisionReason = "memory_high"
6565
BackgroundDecisionAutomaticDisabled BackgroundDecisionReason = "automatic_disabled"
6666
)
6767

@@ -103,9 +103,9 @@ type BackgroundTaskDedupeEntry struct {
103103
// dedupeSlot 记录一个 (class, dedupeKey) 的 running/pending 状态。同一 slot 同时最多
104104
// 一个 running + 一个 pending;超过的请求被 coalesce 拒绝。
105105
type dedupeSlot struct {
106-
running bool
107-
pending bool
108-
priority BackgroundTaskPriority
106+
running bool
107+
pending bool
108+
priority BackgroundTaskPriority
109109
startedAt time.Time
110110
}
111111

@@ -205,6 +205,18 @@ func (c *BackgroundTaskCoordinator) ForegroundActive() bool {
205205
return c.foregroundActive > 0
206206
}
207207

208+
// LoadSnapshot 返回当前系统负载快照。loadFn 未注入时返回零值(所有字段 -1=unknown)。
209+
// 供 protoCache rebuild 等后台任务做动态速度控制。
210+
func (c *BackgroundTaskCoordinator) LoadSnapshot() BackgroundLoadSnapshot {
211+
c.mu.Lock()
212+
fn := c.loadFn
213+
c.mu.Unlock()
214+
if fn == nil {
215+
return BackgroundLoadSnapshot{CPUUserPct: -1, CPUIOWaitPct: -1, MemUsedPct: -1}
216+
}
217+
return fn()
218+
}
219+
208220
// CanRun 评估一次后台任务请求是否可以运行,不占用 slot。用于调用方在启动重工作前快速
209221
// 检查。返回的 decision 不改变 coordinator 状态。
210222
//
@@ -227,6 +239,7 @@ func (c *BackgroundTaskCoordinator) CanRun(req BackgroundTaskRequest) (Backgroun
227239
// 对于 DedupeKey 非空的 automatic 请求,若当前已有 running:
228240
// - 若尚无 pending → 记录一个 pending slot 并返回 coalesced(调用方可选择稍后重试);
229241
// - 若已有 pending → 返回 coalesced,不增加 pending(至多一 pending)。
242+
//
230243
// running 释放时不会自动触发 pending(pending 仅作为“有积压”的可观测标记;实际重试由
231244
// worker 调度循环驱动,避免引入任务队列语义)。
232245
func (c *BackgroundTaskCoordinator) Begin(req BackgroundTaskRequest) (func(), BackgroundTaskDecision, bool) {
@@ -414,7 +427,7 @@ type BackgroundTaskStatus struct {
414427
CPUPauseThreshold float64
415428
IOWaitPauseThreshold float64
416429
MemoryPauseThreshold float64
417-
DBLockedCooldownMs int64
430+
DBLockedCooldownMs int64
418431
}
419432

420433
// Status 返回 Snapshot + 配置/阈值的完整状态视图(供状态 API,Task 16)。
@@ -431,7 +444,7 @@ func (c *BackgroundTaskCoordinator) Status() BackgroundTaskStatus {
431444
CPUPauseThreshold: c.cpuPauseThreshold,
432445
IOWaitPauseThreshold: c.iowaitPauseThreshold,
433446
MemoryPauseThreshold: c.memoryPauseThreshold,
434-
DBLockedCooldownMs: c.dbLockedCooldown.Milliseconds(),
447+
DBLockedCooldownMs: c.dbLockedCooldown.Milliseconds(),
435448
}
436449
now := time.Now()
437450
for class, until := range c.cooldowns {

0 commit comments

Comments
 (0)