Files
buzz/mobile/test/shared/profile/user_cache_provider_test.dart
Tom Brow a540e7bd30 perf(mobile): don't refetch profiles that are already on the wire (#9447)
`UserCacheNotifier.preload` skipped profiles already queued but not ones
already on the wire. Opening a channel preloads the same people from
several places (compose bar, DM header, member list); a preload that
arrives while the first batch is in flight queues those people again,
and the next batch downloads their profiles a second time. On Block's
Builderlab, opening one channel sent the same 77-author kind 0 request
twice, 521 KB each.

A preload now leaves out profiles whose batch is in flight and waits for
that batch instead. Only profiles that are neither cached, queued nor in
flight start a new batch.

### Validation

Added test: while a batch for `a` and `b` is on the wire, preloads for
`b` and for `b, c` send one more request, for `c` only, and all three
preloads complete once their profiles arrive. It fails without this
change (the second request asks for `b` again). Existing user cache
tests pass; full mobile test suite and `flutter analyze` pass.

On an iPhone 12 mini signed in to Block's Builderlab (instrumented
profile build), opening two channels in a row: before this change the
second channel downloaded its 77 members' profiles twice (1,019 KB of
kind 0 events), and the first downloaded its 43 twice (50 KB). With it,
each downloaded them once: 509 KB and 25 KB.

### Regressions

None expected. A caller whose profiles are in flight now waits for that
batch instead of a new one, so it resolves no later than before; a
failed in-flight batch reports failure to it, as its own batch would
have.

---------

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: studio <dc2fc481343672df31a689840c09299972e704dac592e0da6f18486646a7ee3e@localhost>
2026-11-13 13:39:51 -08:00

557 lines
18 KiB
Dart

import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
import 'package:buzz/shared/profile/user_cache_provider.dart';
import 'package:buzz/shared/profile/profile_event_parser.dart';
import 'package:buzz/shared/relay/relay.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:nostr/nostr.dart' as nostr;
import 'package:pointycastle/digests/sha256.dart';
void main() {
test('preload reports a profile batch failure', () async {
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(_FailingProfileSession.new),
],
);
addTearDown(container.dispose);
final succeeded = await container.read(userCacheProvider.notifier).preload(
const ['agent'],
);
expect(succeeded, isFalse);
});
test('refresh queries profiles that are already cached', () async {
final session = _RecordingProfileSession();
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
cache.cacheProfileEvent(
_profileEvent(id: 'cached-profile', createdAt: 1, name: 'Cached Human'),
);
final succeeded = await cache.refresh(const ['AGENT']);
expect(succeeded, isTrue);
expect(session.requestedFilter?.kinds, const [0]);
expect(session.requestedFilter?.authors, const ['agent']);
expect(session.requestedFilter?.limit, 1);
});
test('older refresh cannot overwrite a newer live profile', () async {
final refreshCompleter = Completer<List<NostrEvent>>();
final session = _RecordingProfileSession(result: refreshCompleter.future);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
final owner = nostr.Keys.generate();
final agent = nostr.Keys.generate();
final refresh = cache.refresh([agent.public]);
cache.cacheProfileEvent(
_profileEvent(
id: 'newer-agent',
pubkey: agent.public,
createdAt: 2,
name: 'Agent',
tags: [_authTag(owner, agent.public)],
),
);
refreshCompleter.complete([
_profileEvent(
id: 'older-human',
pubkey: agent.public,
createdAt: 1,
name: 'Human',
),
]);
expect(await refresh, isTrue);
expect(cache.state[agent.public]?.displayName, 'Agent');
expect(cache.state[agent.public]?.ownerPubkey, owner.public);
});
test('newer refresh can remove obsolete owner attribution', () async {
final owner = nostr.Keys.generate();
final agent = nostr.Keys.generate();
final session = _RecordingProfileSession(
result: Future.value([
_profileEvent(
id: 'newer-human',
pubkey: agent.public,
createdAt: 2,
name: 'Human',
),
]),
);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
cache.cacheProfileEvent(
_profileEvent(
id: 'older-agent',
pubkey: agent.public,
createdAt: 1,
name: 'Agent',
tags: [_authTag(owner, agent.public)],
),
);
expect(await cache.refresh([agent.public]), isTrue);
expect(cache.state[agent.public]?.displayName, 'Human');
expect(cache.state[agent.public]?.ownerPubkey, isNull);
});
test('non-profile history cannot poison profile order', () async {
final session = _RecordingProfileSession(
results: [
Future.value([
_profileEvent(
id: 'non-profile-newer',
createdAt: 3,
name: 'Ignored',
kind: 1,
),
]),
Future.value([
_profileEvent(id: 'valid-older', createdAt: 2, name: 'Valid'),
]),
],
);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
expect(await cache.refresh(const ['agent']), isTrue);
expect(cache.state['agent'], isNull);
expect(await cache.refresh(const ['agent']), isTrue);
expect(cache.state['agent']?.displayName, 'Valid');
});
for (final operation in ['refresh', 'preload', 'resolve']) {
test('$operation merges after parsing against newer live state', () async {
final started = Completer<void>();
final release = Completer<void>();
final session = _RecordingProfileSession(
result: Future.value([
_profileEvent(id: 'old', createdAt: 1, name: 'Old'),
]),
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
profileEventBatchParserProvider.overrideWithValue((events) async {
final parsed = await parseProfileEventBatch(events);
started.complete();
await release.future;
return parsed;
}),
],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
final result = switch (operation) {
'preload' => cache.preload(['agent']),
'resolve' => cache.resolve(['agent']).then((_) => true),
_ => cache.refresh(['agent']),
};
await started.future;
cache.cacheProfileEvent(
_profileEvent(id: 'new', createdAt: 2, name: 'Live'),
);
cache.cacheProfileEvent(
_profileEvent(
id: 'other',
pubkey: 'other',
createdAt: 1,
name: 'Other',
),
);
release.complete();
expect(await result, isTrue);
expect(cache.state['agent']?.displayName, 'Live');
expect(cache.state['other']?.displayName, 'Other');
});
test('$operation rejects a retired parsing completion', () async {
final started = Completer<void>();
final release = Completer<void>();
final session = _RecordingProfileSession(
result: Future.value([
_profileEvent(id: 'old', createdAt: 1, name: 'Previous community'),
]),
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
profileEventBatchParserProvider.overrideWithValue((events) async {
final parsed = await parseProfileEventBatch(events);
started.complete();
await release.future;
return parsed;
}),
],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
final result = switch (operation) {
'preload' => cache.preload(['agent']),
'resolve' => cache.resolve(['agent']).then((_) => true),
_ => cache.refresh(['agent']),
};
await started.future;
// This is the same dependency invalidation caused by community config.
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://next-community.invalid');
container.read(userCacheProvider);
release.complete();
if (operation == 'resolve') {
await expectLater(result, throwsStateError);
} else {
expect(await result, isFalse);
}
expect(container.read(userCacheProvider), isEmpty);
});
}
test(
'community change rejects an old network response before parsing',
() async {
final response = Completer<List<NostrEvent>>();
var parsed = false;
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(
() => _RecordingProfileSession(result: response.future),
),
profileEventBatchParserProvider.overrideWithValue((events) async {
parsed = true;
return parseProfileEventBatch(events);
}),
],
);
addTearDown(container.dispose);
final result = container.read(userCacheProvider.notifier).refresh([
'agent',
]);
container
.read(relayConfigProvider.notifier)
.update(baseUrl: 'https://next-community.invalid');
container.read(userCacheProvider);
response.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]);
expect(await result, isFalse);
expect(parsed, isFalse);
expect(container.read(userCacheProvider), isEmpty);
},
);
test(
'parser failure reports refresh failure without modifying cache',
() async {
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(
() => _RecordingProfileSession(
result: Future.value([
_profileEvent(id: 'next', createdAt: 2, name: 'Next'),
]),
),
),
profileEventBatchParserProvider.overrideWithValue(
(_) => Future.error(StateError('parse failed')),
),
],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
cache.cacheProfileEvent(
_profileEvent(id: 'cached', createdAt: 1, name: 'Cached'),
);
expect(await cache.refresh(['agent']), isFalse);
expect(cache.state['agent']?.displayName, 'Cached');
},
);
test('later refresh completion cannot roll back a newer refresh', () async {
final oldResponse = Completer<List<NostrEvent>>();
final session = _RecordingProfileSession(
results: [
oldResponse.future,
Future.value([_profileEvent(id: 'new', createdAt: 2, name: 'New')]),
],
);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
final old = cache.refresh(['agent']);
expect(await cache.refresh(['agent']), isTrue);
oldResponse.complete([_profileEvent(id: 'old', createdAt: 1, name: 'Old')]);
expect(await old, isTrue);
expect(cache.state['agent']?.displayName, 'New');
});
test('stale malformed profile is skipped before parsing', () async {
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(
() => _RecordingProfileSession(
result: Future.value([
NostrEvent(
id: 'old',
pubkey: 'agent',
createdAt: 1,
kind: 0,
tags: [],
content: '{"name":42}',
sig: 'sig',
),
]),
),
),
],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
cache.cacheProfileEvent(
_profileEvent(id: 'new', createdAt: 2, name: 'New'),
);
expect(await cache.refresh(['agent']), isTrue);
expect(cache.state['agent']?.displayName, 'New');
});
test(
'refresh and preload share one verification worker and coalesce pending lookups',
() async {
final started = Completer<void>();
final release = Completer<void>();
var running = 0;
var maxRunning = 0;
var calls = 0;
final session = _RecordingProfileSession(
results: [
Future.value([
_profileEvent(id: 'a', pubkey: 'a', createdAt: 1, name: 'A'),
]),
Future.value([
_profileEvent(id: 'b', pubkey: 'b', createdAt: 1, name: 'B'),
]),
Future.value([
_profileEvent(id: 'c', pubkey: 'c', createdAt: 1, name: 'C'),
]),
],
);
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(() => session),
profileEventBatchParserProvider.overrideWithValue((events) async {
running++;
if (running > maxRunning) maxRunning = running;
if (calls++ == 0) {
started.complete();
await release.future;
}
final parsed = await parseProfileEventBatch(events);
running--;
return parsed;
}),
],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
final first = cache.preload(['a']);
await started.future;
final refresh = cache.refresh(['b']);
final next = cache.preload(['c']);
final duplicate = cache.preload(['c']);
// Let the refresh reach the worker queue before releasing its predecessor.
await Future<void>.delayed(Duration.zero);
expect(calls, 1);
release.complete();
expect(
await Future.wait([first, refresh, next, duplicate]),
everyElement(isTrue),
);
expect(maxRunning, 1);
expect(calls, 3);
expect(session.requestCount, 3);
},
);
test('a preload never asks again for profiles already on the wire', () async {
final firstBatch = Completer<List<NostrEvent>>();
final session = _RecordingProfileSession(
results: [
firstBatch.future,
Future.value([
_profileEvent(id: 'c', pubkey: 'c', createdAt: 1, name: 'C'),
]),
],
);
final container = ProviderContainer(
overrides: [relaySessionProvider.overrideWith(() => session)],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
final first = cache.preload(['a', 'b']);
await session.firstRequest.future;
expect(session.requestCount, 1);
// b is on the wire; only c is new.
final again = cache.preload(['b']);
final more = cache.preload(['b', 'c']);
firstBatch.complete([
_profileEvent(id: 'a', pubkey: 'a', createdAt: 1, name: 'A'),
_profileEvent(id: 'b', pubkey: 'b', createdAt: 1, name: 'B'),
]);
expect(await Future.wait([first, again, more]), everyElement(isTrue));
expect(session.requestCount, 2);
expect(session.requestedFilter?.authors, ['c']);
expect(cache.state.keys, containsAll(['a', 'b', 'c']));
});
test(
'refresh verifies real OA batch while allowing main-isolate timers',
() async {
final owner = nostr.Keys.generate();
final agents = List.generate(16, (_) => nostr.Keys.generate());
final events = [
for (final agent in agents)
_profileEvent(
id: agent.public,
pubkey: agent.public,
createdAt: 1,
name: 'Synthetic',
tags: [_authTag(owner, agent.public)],
),
];
var timerRanBeforeParsingCompleted = false;
final container = ProviderContainer(
overrides: [
relaySessionProvider.overrideWith(
() => _RecordingProfileSession(result: Future.value(events)),
),
profileEventBatchParserProvider.overrideWithValue((events) async {
var timerRan = false;
final timer = Timer(Duration.zero, () => timerRan = true);
final parsed = await parseProfileEventBatch(events);
timerRanBeforeParsingCompleted = timerRan;
timer.cancel();
return parsed;
}),
],
);
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
expect(await cache.refresh(agents.map((a) => a.public).toList()), isTrue);
expect(timerRanBeforeParsingCompleted, isTrue);
for (final agent in agents) {
expect(cache.state[agent.public]?.ownerPubkey, owner.public);
}
},
);
test('same-second profile tie keeps the lowest event id', () {
final container = ProviderContainer();
addTearDown(container.dispose);
final cache = container.read(userCacheProvider.notifier);
cache.cacheProfileEvent(
_profileEvent(id: 'b', createdAt: 1, name: 'Larger ID'),
);
cache.cacheProfileEvent(
_profileEvent(id: 'a', createdAt: 1, name: 'Lower ID'),
);
cache.cacheProfileEvent(
_profileEvent(id: 'c', createdAt: 1, name: 'Later Larger ID'),
);
expect(cache.state['agent']?.displayName, 'Lower ID');
});
}
NostrEvent _profileEvent({
required String id,
required int createdAt,
required String name,
String pubkey = 'agent',
List<List<String>> tags = const [],
int kind = 0,
}) => NostrEvent(
id: id,
pubkey: pubkey,
createdAt: createdAt,
kind: kind,
tags: tags,
content: jsonEncode({'name': name}),
sig: 'sig',
);
List<String> _authTag(nostr.Keys owner, String agentPubkey) {
final digest = SHA256Digest().process(
Uint8List.fromList(
utf8.encode('nostr:agent-auth:${agentPubkey.toLowerCase()}:'),
),
);
final message = digest
.map((byte) => byte.toRadixString(16).padLeft(2, '0'))
.join();
final signature = nostr.Schnorr.sign(
secretKey: owner.secret,
message: message,
);
return ['auth', owner.public, '', signature];
}
class _RecordingProfileSession extends RelaySessionNotifier {
_RecordingProfileSession({
Future<List<NostrEvent>>? result,
List<Future<List<NostrEvent>>>? results,
}) : _results = [...?results, ?result];
final List<Future<List<NostrEvent>>> _results;
NostrFilter? requestedFilter;
int requestCount = 0;
final firstRequest = Completer<void>();
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) async {
requestCount++;
requestedFilter = filter;
if (!firstRequest.isCompleted) firstRequest.complete();
return _results.isEmpty ? const [] : _results.removeAt(0);
}
}
class _FailingProfileSession extends RelaySessionNotifier {
@override
SessionState build() => const SessionState(status: SessionStatus.connected);
@override
Future<List<NostrEvent>> fetchHistory(
NostrFilter filter, {
Duration timeout = const Duration(seconds: 8),
}) => Future.error('profile unavailable');
}