Skip to content

Commit 4e164b0

Browse files
committed
feat(people): 引入 protoCache 脏队列驱动增量刷新
将 prototype cache 从 TTL 定时重建改为前台变更驱动的增量刷新,避免每 批次全量重载 22 万行原型人脸。 - 新增 protoCacheDirtyState 跟踪前台 mutation(合并/拆分/移动/指派/ 解散/重置),markProtoCacheDirty 记录受影响 personID,worker 在安静 窗口后统一应用 - shouldRefreshProtoCache 新增脏队列判定与冷启动旁路:nil cache 跳过 安静窗口但仍受失败 cooldown 约束 - refreshProtoCacheOutsideGate 采用增量优先策略:dirty≤阈值时仅重载受 影响 person(refreshProtoCacheIncremental),超过阈值或冷启动走全量 buildClustProtoCache,删除 person 通过 applyTombstonesToCache 移除 - clustProtoCacheTTL 由 5 分钟延长至 6 小时,仅作兜底安全网 - runIncrementalClustering 移除 writeGate 内隐式构建兜底,protoCache 为 nil 时返回错误由 coordinator 在锁外刷新后重试 - 测试注入 1ms 安静窗口以保持现有即时聚类预期
1 parent 3136b31 commit 4e164b0

3 files changed

Lines changed: 289 additions & 32 deletions

File tree

backend/internal/service/people_clustering_coordinator.go

Lines changed: 92 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ type peopleClusteringCoordinator struct {
108108
// 0 时使用包级常量。仅供测试通过 setProtoCacheRefreshIntervalsForTest 设置。
109109
protoCacheMinInterval time.Duration
110110
protoCacheFailCool time.Duration
111+
protoCacheQuietWin time.Duration
111112

112113
workerDone chan struct{}
113114
}
@@ -341,6 +342,7 @@ func (c *peopleClusteringCoordinator) runClusterBatch(source clusterSource) back
341342
DirtyPersonIDs: res.affectedPersonIDs,
342343
Reason: "clustering_assignment",
343344
})
345+
c.svc.markProtoCacheDirty(res.affectedPersonIDs, nil, "clustering_assignment")
344346
}
345347

346348
if res.err != nil {
@@ -382,36 +384,61 @@ func (c *peopleClusteringCoordinator) protoCacheRefreshFailCooldownValue() time.
382384
return protoCacheRefreshFailCooldown
383385
}
384386

385-
// shouldRefreshProtoCache 报告本批次是否应尝试刷新 protoCache。规则(Task 10):
386-
// - protoCache 不需要 refresh(fresh)→ false。
387-
// - 已有一个 refresh running → false(至多一 running,coalesce 进 running)。
388-
// - 在成功最小间隔 cooldown 内 → false(保持 pending)。
389-
// - 否则 → true。
387+
// shouldRefreshProtoCache reports whether this batch should attempt a protoCache refresh.
388+
// Rules (dirty-queue + pressure-aware refresh):
389+
// - protoCache does not need refresh (fresh, no dirty) -> false.
390+
// - A refresh is already running -> false (coalesce into running).
391+
// - Within success/failure cooldown -> false (keep pending).
392+
// - Dirty entries exist but quiet window not yet elapsed -> false (keep pending).
393+
// - Otherwise -> true.
390394
//
391-
// pending 状态不阻止下一次尝试:pending 表示“上次想刷但没刷成”,本次应继续尝试。
392-
// 只由 worker goroutine 调用,无需加锁。
395+
// Cold start (nil cache) bypasses quiet window and cooldown: the worker must build
396+
// before it can cluster at all. Only the worker goroutine calls this.
393397
func (c *peopleClusteringCoordinator) shouldRefreshProtoCache() bool {
394398
if !c.svc.protoCacheNeedsRefresh() {
395399
return false
396400
}
397401
if c.protoCacheRefreshRunning {
398402
return false
399403
}
404+
// Cold start bypasses quiet window but still respects failure cooldown to avoid
405+
// spinning on repeated build failures. Success cooldown is also bypassed because
406+
// there is no cache to serve clustering.
407+
if c.svc.protoCache == nil {
408+
if time.Now().Before(c.protoCacheRefreshCooldownUntil) {
409+
c.protoCacheRefreshPending = true
410+
return false
411+
}
412+
return true
413+
}
400414
if time.Now().Before(c.protoCacheRefreshCooldownUntil) {
401-
// 成功 cooldown 内:保持 pending,不刷新。
415+
c.protoCacheRefreshPending = true
416+
return false
417+
}
418+
// Quiet window: wait for foreground activity to settle before refreshing.
419+
_, _, _, _, lastDirtyAt, _ := c.svc.snapshotProtoCacheDirty()
420+
if !lastDirtyAt.IsZero() && time.Since(lastDirtyAt) < c.protoCacheQuietWindowValue() {
402421
c.protoCacheRefreshPending = true
403422
return false
404423
}
405424
return true
406425
}
407426

408-
// refreshProtoCacheOutsideGate 在 writeGate 外构建 protoCache。返回:
409-
// - refreshed=true:成功构建并赋值给 s.protoCache。
410-
// - skip=true:foreground active 阻止了 refresh(启动前或构建中途变 active),调用方应
411-
// return no work 并保持 pending。
412-
// - err!=nil:构建失败,调用方应进入失败 cooldown。
427+
// refreshProtoCacheOutsideGate refreshes protoCache outside writeGate. Returns:
428+
// - refreshed=true: successfully refreshed (incremental or full) and assigned to s.protoCache.
429+
// - skip=true: foreground active prevented refresh (startup or mid-build), caller should
430+
// return no work and keep pending.
431+
// - err!=nil: build failed, caller should enter failure cooldown.
432+
//
433+
// refreshed and skip are mutually exclusive; err non-nil means the others are zero.
434+
// Only called by the worker goroutine.
413435
//
414-
// refreshed 与 skip 互斥;err 非 nil 时其余两者为零值。只由 worker goroutine 调用。
436+
// Refresh strategy (incremental-first, full-fallback):
437+
// - If cache is nil (cold start) or fullRebuildNeeded: full rebuild via buildClustProtoCache.
438+
// - If dirty person count <= protoCacheIncrementalThreshold: incremental refresh of affected
439+
// persons only, avoiding the 220K-row full reload.
440+
// - If dirty person count exceeds threshold: full rebuild.
441+
// - Tombstones (deleted persons) are applied to the cache before any clustering batch.
415442
func (c *peopleClusteringCoordinator) refreshProtoCacheOutsideGate(source clusterSource) (refreshed, skip bool, err error) {
416443
if c.svc.backgroundCoordinator != nil && c.svc.backgroundCoordinator.ForegroundActive() {
417444
logger.Infof("people clustering coordinator: source=%s skip protoCache refresh: foreground active", source)
@@ -420,16 +447,47 @@ func (c *peopleClusteringCoordinator) refreshProtoCacheOutsideGate(source cluste
420447
c.protoCacheRefreshRunning = true
421448
defer func() { c.protoCacheRefreshRunning = false }()
422449

423-
newCache, buildErr := c.svc.buildClustProtoCache()
424-
if buildErr != nil {
425-
return false, false, buildErr
450+
// Snapshot the dirty state to decide refresh strategy.
451+
gen, dirtyIDs, deletedIDs, reasons, _, fullRebuild := c.svc.snapshotProtoCacheDirty()
452+
453+
// Determine refresh mode.
454+
cacheIsNil := c.svc.protoCache == nil
455+
dirtyCount := len(dirtyIDs)
456+
doFullRebuild := cacheIsNil || fullRebuild || dirtyCount > protoCacheIncrementalThreshold
457+
458+
if doFullRebuild {
459+
newCache, buildErr := c.svc.buildClustProtoCache()
460+
if buildErr != nil {
461+
return false, false, buildErr
462+
}
463+
if c.svc.backgroundCoordinator != nil && c.svc.backgroundCoordinator.ForegroundActive() {
464+
logger.Infof("people clustering coordinator: source=%s discard rebuilt protoCache: foreground became active", source)
465+
return false, true, nil
466+
}
467+
c.svc.protoCache = newCache
468+
c.svc.clearProtoCacheDirty(gen)
469+
logger.Infof("people clustering coordinator: source=%s protoCache full rebuild dirty=%d deleted=%d reasons=%v",
470+
source, dirtyCount, len(deletedIDs), reasons)
471+
return true, false, nil
472+
}
473+
474+
// Incremental refresh: reload only affected persons.
475+
// First apply tombstones to remove deleted persons from cache.
476+
c.svc.applyTombstonesToCache(deletedIDs)
477+
if len(dirtyIDs) > 0 {
478+
n, refreshErr := c.svc.refreshProtoCacheIncremental(dirtyIDs)
479+
if refreshErr != nil {
480+
return false, false, refreshErr
481+
}
482+
_ = n
426483
}
427-
// 构建完成后再检查 foreground:若变 active,丢弃结果 return no work。
428484
if c.svc.backgroundCoordinator != nil && c.svc.backgroundCoordinator.ForegroundActive() {
429-
logger.Infof("people clustering coordinator: source=%s discard rebuilt protoCache: foreground became active", source)
485+
logger.Infof("people clustering coordinator: source=%s discard incremental refresh: foreground became active", source)
430486
return false, true, nil
431487
}
432-
c.svc.protoCache = newCache
488+
c.svc.clearProtoCacheDirty(gen)
489+
logger.Infof("people clustering coordinator: source=%s protoCache incremental refresh dirty=%d deleted=%d reasons=%v",
490+
source, dirtyCount, len(deletedIDs), reasons)
433491
return true, false, nil
434492
}
435493

@@ -439,12 +497,25 @@ func (c *peopleClusteringCoordinator) recordProtoCacheRefreshFailure() {
439497
c.protoCacheRefreshCooldownUntil = time.Now().Add(c.protoCacheRefreshFailCooldownValue())
440498
}
441499

442-
// setProtoCacheRefreshIntervalsForTest 覆盖 cooldown/min-interval(仅供测试)。
500+
// protoCacheQuietWindowValue returns the quiet window duration (test-configurable, 0 uses package constant).
501+
func (c *peopleClusteringCoordinator) protoCacheQuietWindowValue() time.Duration {
502+
if c.protoCacheQuietWin > 0 {
503+
return c.protoCacheQuietWin
504+
}
505+
return protoCacheQuietWindow
506+
}
507+
508+
// setProtoCacheRefreshIntervalsForTest 覆盖 cooldown/min-interval/quiet-window(仅供测试)。
443509
func (c *peopleClusteringCoordinator) setProtoCacheRefreshIntervalsForTest(minInterval, failCooldown time.Duration) {
444510
c.protoCacheMinInterval = minInterval
445511
c.protoCacheFailCool = failCooldown
446512
}
447513

514+
// setProtoCacheQuietWindowForTest overrides the quiet window duration (test only).
515+
func (c *peopleClusteringCoordinator) setProtoCacheQuietWindowForTest(d time.Duration) {
516+
c.protoCacheQuietWin = d
517+
}
518+
448519
// submitBackground requests one background clustering batch and blocks until
449520
// the worker executes it (coalesced with any concurrent background requests)
450521
// and returns the batch result. Safe to call from multiple goroutines.

0 commit comments

Comments
 (0)