@@ -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.
393397func (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.
415442func (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(仅供测试)。
443509func (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