From 0e5a69962e5cbe5e81ea46b557770ce27a120a14 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Tue, 3 Nov 2026 09:16:43 -0800 Subject: [PATCH] perf(mobile): evict verified-profile interest in constant time (#8931) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `VerifiedProfilesNotifier.track` trims its interest map to 1,024 subjects, but it finds the oldest subject by scanning every entry, once per eviction. Opening #announcements (4,302 members) evicts about 3,300 subjects, which is roughly 3.4 million comparisons. On a 12 mini Profile build that was 316 ms of main thread, inside the channel's 134 to 167 ms opening frames. It runs whether or not the relay serves verified profiles, so it also affects the prod Block community. A subject's interest is set once, on insertion (`??=`), so the map's insertion order is interest order. The oldest subject is therefore `keys.first`, and eviction is constant time. Which subjects are kept does not change. ### Related issue None found. ### Testing - New `verified_profile_directory_test.dart` case: tracking 4,300 subjects keeps exactly the 1,024 most recently tracked ones, and tracking a subject again does not refresh its age. This test checks that behavior is preserved, and it passes on main too. The speedup comes from removing the per-eviction scan, which the device CPU profile attributed. - `just mobile-check` passes. `flutter test test/shared/verified_profiles/` passes (30 tests). The pre-push hook ran the mobile unit tests. - Device (12 mini, Profile build, opening #announcements, first 5 s; runs not matched): `VerifiedProfilesNotifier.track` inclusive CPU 316 ms before, 14 ms after. Opening frames of 134 to 141 ms remain, mostly the channel name scope (follow-up). Not covered: a human test (waived under the mobile testing exception), Android. Separately, the 1,024 cap still churns subscriptions for rosters larger than that. This PR doesn't change that design. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Tom Brow Co-authored-by: Tom Brow Co-authored-by: studio Co-authored-by: Claude Opus 5.5 --- .../verified_profile_directory.dart | 20 +++-- .../verified_profile_directory_test.dart | 82 ++++++++++++++++++- 2 files changed, 94 insertions(+), 8 deletions(-) diff --git a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart index d57a8301b0..2a64dc6679 100644 --- a/mobile/lib/shared/verified_profiles/verified_profile_directory.dart +++ b/mobile/lib/shared/verified_profiles/verified_profile_directory.dart @@ -61,7 +61,7 @@ class _Batch { /// Records survive disconnects as `checked: false` (cached) and are only /// reconciled by a completed subscription on the current connection. class VerifiedProfilesNotifier extends Notifier> { - final Map _wanted = {}; + late final Map _wanted = newInterestMap(); final Map _heads = {}; final Set _checked = {}; final Map _batches = {}; @@ -76,6 +76,11 @@ class VerifiedProfilesNotifier extends Notifier> { (String, String?)? _scope; Timer? _refreshTimer; + /// The interest map, insertion-ordered by interest. Tests override this to + /// count how many entries eviction visits. + @visibleForTesting + Map newInterestMap() => {}; + @override Map build() { final scope = ref.watch( @@ -128,13 +133,14 @@ class VerifiedProfilesNotifier extends Notifier> { added |= !_wanted.containsKey(key); _wanted[key] ??= ++_interest; } + // A key's interest is set once, when it is inserted, so the map's + // insertion order is interest order and the oldest key is the first. A + // scan per eviction made one 4,000-member roster take ~300 ms on device. while (_wanted.length > _wantedLimit) { - final oldest = _wanted.entries.reduce( - (a, b) => a.value < b.value ? a : b, - ); - _wanted.remove(oldest.key); - _heads.remove(oldest.key); - _checked.remove(oldest.key); + final oldest = _wanted.keys.first; + _wanted.remove(oldest); + _heads.remove(oldest); + _checked.remove(oldest); added = true; } if (!added) return; diff --git a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart index 01f574d509..e6f6bba295 100644 --- a/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart +++ b/mobile/test/shared/verified_profiles/verified_profile_directory_test.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:collection'; import 'package:buzz/shared/profile/user_cache_provider.dart'; import 'package:buzz/shared/relay/relay.dart'; @@ -13,10 +14,52 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../helpers/fake_relay.dart'; import 'verified_profile_fixture.dart'; +/// Counts the keys read through [keys], which [entries] and [values] also use. +class _CountingMap extends MapBase { + _CountingMap(this.visits); + + final void Function() visits; + final Map _inner = {}; + + @override + int? operator [](Object? key) => _inner[key]; + @override + void operator []=(String key, int value) => _inner[key] = value; + @override + void clear() => _inner.clear(); + @override + int? remove(Object? key) => _inner.remove(key); + @override + int get length => _inner.length; + @override + bool get isEmpty => _inner.isEmpty; + @override + bool get isNotEmpty => _inner.isNotEmpty; + @override + bool containsKey(Object? key) => _inner.containsKey(key); + @override + Iterable get keys sync* { + for (final key in _inner.keys) { + visits(); + yield key; + } + } +} + +class _CountingNotifier extends VerifiedProfilesNotifier { + _CountingNotifier(this.visits); + + final void Function() visits; + + @override + Map newInterestMap() => _CountingMap(visits); +} + class Harness { - Harness(this.async) { + Harness(this.async, {VerifiedProfilesNotifier Function()? notifier}) { container = ProviderContainer( overrides: [ + if (notifier != null) verifiedProfilesProvider.overrideWith(notifier), ...relay.overrides, verifiedProfileDiscoveryFetchProvider.overrideWith( (ref) => () async { @@ -66,6 +109,26 @@ void journey(String name, void Function(Harness h) body) { } void main() { + test('evicting past the interest limit does not scan the interest map', () { + fakeAsync((async) { + var visits = 0; + final h = Harness( + async, + notifier: () => _CountingNotifier(() => visits++), + ); + addTearDown(h.container.dispose); + String key(int i) => i.toRadixString(16).padLeft(64, '0'); + h.start([for (var i = 0; i < 1024; i++) key(i)]); + visits = 0; + // Each new subject evicts one: 3,276 evictions. Scanning the map for the + // oldest entry each time would visit about a thousand keys apiece. + h.container.read(verifiedProfilesProvider.notifier).track([ + for (var i = 1024; i < 4300; i++) key(i), + ]); + expect(visits, lessThanOrEqualTo(4300 - 1024)); + }); + }); + journey('no marks until discovery succeeds and advertises public_profile', ( h, ) { @@ -102,6 +165,23 @@ void main() { expect(sizes..sort(), [2, 128]); }); + journey('a large roster keeps the 1,024 most recently tracked subjects', (h) { + String key(int i) => i.toRadixString(16).padLeft(64, '0'); + h.start([for (var i = 0; i < 10; i++) key(i)]); + final before = h.dFilters.length; + // Tracking a subject again does not refresh its age. + h.container.read(verifiedProfilesProvider.notifier).track([ + key(0), + for (var i = 10; i < 4300; i++) key(i), + ]); + h.settle(); + final subscribed = { + for (final f in h.dFilters.skip(before)) + ...(f['#d'] as List).cast(), + }; + expect(subscribed, {for (var i = 4300 - 1024; i < 4300; i++) key(i)}); + }); + journey('live updates, retractions and ordering apply on the subscription', ( h, ) {