fix(sync): calm vector-clock pruning notification (#8696) (#8699)

Users with several past installs/reinstalls accumulate >20 client IDs in
the vector clock, so pruning fires on nearly every download-merge. The old
notice was a sticky WARNING recommending a destructive "sync reset" for a
benign, self-healing cleanup, and it recurred constantly.

Split the signal by purpose without touching pruning correctness:
- Log: OpLog.info -> OpLog.warn, plus prunedIds/survivingIds so the churn
  is diagnosable from the exported log history users share in reports.
- Snack: throttle to once per app session (take(1)); WARNING -> CUSTOM
  with a neutral icon and auto-dismiss instead of a sticky alert.
- Reword the message: drop "exceeding the limit"/"sync reset", keep the
  numbers as a breadcrumb.
This commit is contained in:
Johannes Millan 2026-07-02 12:18:14 +02:00 committed by GitHub
parent 4e0e2d9ffd
commit 6b375db007
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 77 additions and 6 deletions

View file

@ -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<string, number> = { 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<string, number> = {
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', () => {

View file

@ -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;
};

View file

@ -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 },
});
}),
),

View file

@ -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."