perf(mobile): evict verified-profile interest in constant time (#8931)

## 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 <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@buzz.block.builderlab.xyz>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Tom Brow
2026-11-03 09:16:43 -08:00
committed by GitHub
parent d44031f418
commit 0e5a69962e
2 changed files with 94 additions and 8 deletions

View File

@@ -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<Map<String, VerifiedProfile>> {
final Map<String, int> _wanted = {};
late final Map<String, int> _wanted = newInterestMap();
final Map<String, NostrEvent> _heads = {};
final Set<String> _checked = {};
final Map<String, _Batch> _batches = {};
@@ -76,6 +76,11 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
(String, String?)? _scope;
Timer? _refreshTimer;
/// The interest map, insertion-ordered by interest. Tests override this to
/// count how many entries eviction visits.
@visibleForTesting
Map<String, int> newInterestMap() => {};
@override
Map<String, VerifiedProfile> build() {
final scope = ref.watch(
@@ -128,13 +133,14 @@ class VerifiedProfilesNotifier extends Notifier<Map<String, VerifiedProfile>> {
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;

View File

@@ -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<String, int> {
_CountingMap(this.visits);
final void Function() visits;
final Map<String, int> _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<String> 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<String, int> 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<String>(),
};
expect(subscribed, {for (var i = 4300 - 1024; i < 4300; i++) key(i)});
});
journey('live updates, retractions and ordering apply on the subscription', (
h,
) {