diff --git a/src/app/core/util/vector-clock.spec.ts b/src/app/core/util/vector-clock.spec.ts index b0e6b5ab24..ba61eb4843 100644 --- a/src/app/core/util/vector-clock.spec.ts +++ b/src/app/core/util/vector-clock.spec.ts @@ -3,8 +3,10 @@ import { VectorClockComparison, compareVectorClocks, hasVectorClockChanges, + vectorClockPruned$, } from './vector-clock'; import { MAX_VECTOR_CLOCK_SIZE } from '../../op-log/core/operation-log.const'; +import { OpLog } from '../log'; describe('vector-clock', () => { describe('limitVectorClockSize', () => { @@ -66,6 +68,56 @@ describe('vector-clock', () => { expect(Object.keys(result).length).toBeLessThanOrEqual(MAX_VECTOR_CLOCK_SIZE); }); + + it('should emit on vectorClockPruned$ when pruning occurs (drives the user notice)', () => { + const events: { originalSize: number; maxSize: number }[] = []; + const sub = vectorClockPruned$.subscribe((e) => events.push(e)); + + const clock: Record = { current: 500 }; + const overflow = MAX_VECTOR_CLOCK_SIZE + 3; + for (let i = 0; i < overflow; i++) { + clock[`client_${i}`] = 100 + i; + } + limitVectorClockSize(clock, 'current'); + sub.unsubscribe(); + + expect(events.length).toBe(1); + expect(events[0].originalSize).toBe(overflow + 1); // + current + expect(events[0].maxSize).toBe(MAX_VECTOR_CLOCK_SIZE); + }); + + it('should NOT emit on vectorClockPruned$ when within the limit', () => { + const events: unknown[] = []; + const sub = vectorClockPruned$.subscribe((e) => events.push(e)); + limitVectorClockSize({ a: 1, b: 2 }, 'a'); + sub.unsubscribe(); + expect(events.length).toBe(0); + }); + + it('should log pruned + surviving client IDs at WARN level for bug-report diagnostics', () => { + const warnSpy = spyOn(OpLog, 'warn'); + + const clock: Record = { + current: 500, + staleLow: 1, // lowest counter → gets pruned + }; + for (let i = 0; i < MAX_VECTOR_CLOCK_SIZE; i++) { + clock[`client_${i}`] = 100 + i; + } + + limitVectorClockSize(clock, 'current'); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const payload = warnSpy.calls.mostRecent().args[1] as { + prunedIds: string[]; + survivingIds: string[]; + prunedCount: number; + }; + expect(payload.prunedIds).toContain('staleLow'); + expect(payload.survivingIds).toContain('current'); + expect(payload.prunedCount).toBe(payload.prunedIds.length); + expect(payload.survivingIds.length).toBe(MAX_VECTOR_CLOCK_SIZE); + }); }); describe('compareVectorClocks', () => { diff --git a/src/app/core/util/vector-clock.ts b/src/app/core/util/vector-clock.ts index 75c7097080..1704b3b754 100644 --- a/src/app/core/util/vector-clock.ts +++ b/src/app/core/util/vector-clock.ts @@ -307,11 +307,21 @@ export const limitVectorClockSize = ( return clock; } - OpLog.info('Vector clock pruning triggered', { + const limited = sharedLimitVectorClockSize(clock, [currentClientId]); + const prunedIds = Object.keys(clock).filter((id) => !(id in limited)); + + // WARN (not info): pruning is meant to be rare, so when it fires it is worth + // surfacing in the exported log history users share in bug reports (see issue + // #8696). clientIds are safe to log — they are not user content — and knowing + // which identities keep churning is the key diagnostic for stale-device + // accumulation. + OpLog.warn('Vector clock pruning triggered', { originalSize: entries.length, maxSize: MAX_VECTOR_CLOCK_SIZE, currentClientId, - pruned: entries.length - MAX_VECTOR_CLOCK_SIZE, + prunedCount: prunedIds.length, + prunedIds, + survivingIds: Object.keys(limited), }); vectorClockPruned$.next({ @@ -319,5 +329,5 @@ export const limitVectorClockSize = ( maxSize: MAX_VECTOR_CLOCK_SIZE, }); - return sharedLimitVectorClockSize(clock, [currentClientId]); + return limited; }; diff --git a/src/app/imex/sync/sync.effects.ts b/src/app/imex/sync/sync.effects.ts index 62dc1983b8..81b3801555 100644 --- a/src/app/imex/sync/sync.effects.ts +++ b/src/app/imex/sync/sync.effects.ts @@ -100,15 +100,24 @@ export class SyncEffects { vectorClockPruningNotification$ = createEffect( () => vectorClockPruned$.pipe( + // Pruning fires on essentially every download-merge once a user has + // accumulated >20 client IDs (see #8696). Show a calm breadcrumb at + // most once per app session — enough to recognise it is happening, + // without nagging. The durable record lives in the (WARN-level) log. + take(1), tap(({ originalSize, maxSize }) => { this._snackService.open({ msg: T.F.SYNC.S.VECTOR_CLOCK_LIMIT_REACHED, - type: 'WARNING', + // CUSTOM (not WARNING): this is a benign, self-healing cleanup, not + // an alert the user must act on. Neutral icon, auto-dismiss — but a + // touch longer than the 3s default so the sentence is readable. + type: 'CUSTOM', + ico: 'sync', translateParams: { originalSize, maxSize, }, - config: { duration: 0 }, + config: { duration: 5000 }, }); }), ), diff --git a/src/assets/i18n/en.json b/src/assets/i18n/en.json index 494ea64b6d..d2c211cd3c 100644 --- a/src/assets/i18n/en.json +++ b/src/assets/i18n/en.json @@ -1579,7 +1579,7 @@ "UNKNOWN_ERROR": "Unknown Sync Error: {{err}}", "UPLOAD_ERROR": "Unknown Upload Error (Settings correct?): {{err}}", "UPLOAD_OPS_REJECTED": "{{count}} operation(s) were permanently rejected by the server and won't be synced.", - "VECTOR_CLOCK_LIMIT_REACHED": "Sync has tracked {{originalSize}} devices/browsers, exceeding the limit of {{maxSize}}. Inactive device entries were pruned. Consider a sync reset if you experience issues.", + "VECTOR_CLOCK_LIMIT_REACHED": "Sync tidied up old device entries ({{originalSize}} → {{maxSize}}). This is normal — no action needed.", "VERSION_TOO_OLD": "Your app version is too old for the synced data. Please update!", "VERSION_UNSUPPORTED": "Cannot sync: data requires a newer app version. Please update.", "WEB_CRYPTO_NOT_AVAILABLE": "Encryption is not available on this device. On Android, encryption requires a secure context (HTTPS) which is not available. Please disable encryption in sync settings or sync from a desktop web browser."