mirror of
https://github.com/block/buzz.git
synced 2026-11-14 13:21:02 +00:00
`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>
410 lines
14 KiB
Dart
410 lines
14 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
|
|
import '../relay/relay.dart';
|
|
import '../verified_profiles/verified_profile_directory.dart';
|
|
import 'user_profile.dart';
|
|
import 'profile_event_parser.dart';
|
|
import 'profile_snapshot.dart';
|
|
|
|
/// Cache of user profiles, fetched in batches from the relay.
|
|
///
|
|
/// Verified profiles persist per identity and relay ([ProfileSnapshotStorage])
|
|
/// so names and avatars render on launch, even on a slow link. Restored
|
|
/// profiles are served immediately and revalidated on their first lookup.
|
|
///
|
|
/// Lookups requested via [get] or [preload] are coalesced into a single
|
|
/// kind:0 batch query (NIP-01 `authors` filter) every 50ms. Larger requests
|
|
/// drain sequentially in pages bounded by the relay response limit.
|
|
class UserCacheNotifier extends Notifier<Map<String, UserProfile>> {
|
|
// The relay clamps each history response to DEFAULT_MAX_PAGE_LIMIT (1000).
|
|
static const _maximumProfilesPerQuery = 1000;
|
|
static const _snapshotWriteDelay = Duration(seconds: 2);
|
|
|
|
final Set<String> _pending = {};
|
|
final Map<String, ({int createdAt, String eventId})> _profileEventOrders = {};
|
|
int _generation = 0;
|
|
bool _flushInFlight = false;
|
|
Future<void> _verificationQueue = Future.value();
|
|
Timer? _batchTimer;
|
|
Completer<bool>? _batchCompleter;
|
|
|
|
/// Restored from the snapshot and not yet checked with the relay this scope.
|
|
final Set<String> _unrevalidated = {};
|
|
|
|
/// Lookups whose batch query is on the wire.
|
|
final Set<String> _inFlight = {};
|
|
|
|
/// Completes when the batch query on the wire has finished.
|
|
Completer<bool>? _flightCompleter;
|
|
void Function()? _snapshotWriter;
|
|
Timer? _snapshotTimer;
|
|
Map<String, UserProfile> _latest = const {};
|
|
|
|
@override
|
|
Map<String, UserProfile> build() {
|
|
final config = ref.watch(relayConfigProvider);
|
|
_generation++;
|
|
_profileEventOrders.clear();
|
|
_unrevalidated.clear();
|
|
_inFlight.clear();
|
|
ref.onDispose(() {
|
|
_generation++;
|
|
_pending.clear();
|
|
_batchTimer?.cancel();
|
|
_batchTimer = null;
|
|
_batchCompleter?.complete(false);
|
|
_batchCompleter = null;
|
|
// Flush rather than drop: switching community must not lose the
|
|
// profiles learned since the last write.
|
|
if (_snapshotTimer?.isActive ?? false) _snapshotWriter?.call();
|
|
_snapshotTimer?.cancel();
|
|
_snapshotTimer = null;
|
|
_snapshotWriter = null;
|
|
});
|
|
return _restoreSnapshot(config);
|
|
}
|
|
|
|
Map<String, UserProfile> _restoreSnapshot(RelayConfig config) {
|
|
final storage = ref.read(profileSnapshotStorageProvider);
|
|
final pubkey = pubkeyFromNsec(config.nsec);
|
|
if (storage == null || pubkey == null) return {};
|
|
final relay = config.baseUrl;
|
|
final token = storage.capture(pubkey, relay);
|
|
_snapshotWriter = () {
|
|
final profiles = [
|
|
for (final MapEntry(:key, value: order) in _profileEventOrders.entries)
|
|
if (_latest[key] case final profile?)
|
|
(
|
|
profile: profile,
|
|
createdAt: order.createdAt,
|
|
eventId: order.eventId,
|
|
),
|
|
];
|
|
storage
|
|
.write(token, profiles)
|
|
.then(
|
|
(saved) {
|
|
if (!saved) debugPrint('[UserCache] snapshot was not saved');
|
|
},
|
|
onError: (Object error, StackTrace stack) {
|
|
debugPrint('[UserCache] snapshot write failed: $error');
|
|
},
|
|
);
|
|
};
|
|
final restored = <String, UserProfile>{};
|
|
for (final parsed in storage.read(pubkey, relay)) {
|
|
final key = parsed.profile.pubkey;
|
|
restored[key] = parsed.profile;
|
|
_profileEventOrders[key] = (
|
|
createdAt: parsed.createdAt,
|
|
eventId: parsed.eventId,
|
|
);
|
|
_unrevalidated.add(key);
|
|
}
|
|
return _latest = restored;
|
|
}
|
|
|
|
@override
|
|
set state(Map<String, UserProfile> value) {
|
|
super.state = _latest = value;
|
|
if (_snapshotWriter == null) return;
|
|
_snapshotTimer ??= Timer(_snapshotWriteDelay, () {
|
|
_snapshotTimer = null;
|
|
_snapshotWriter?.call();
|
|
});
|
|
}
|
|
|
|
/// Whether [pubkey] has a profile that is current for this scope.
|
|
bool _isCached(String pubkey) =>
|
|
state.containsKey(pubkey) && !_unrevalidated.contains(pubkey);
|
|
|
|
/// Request a profile for [pubkey]. Returns immediately from cache if
|
|
/// available, otherwise schedules a batch fetch.
|
|
UserProfile? get(String pubkey) {
|
|
_trackVerified([pubkey]);
|
|
final key = pubkey.toLowerCase();
|
|
_scheduleFetch(key);
|
|
return state[key];
|
|
}
|
|
|
|
/// Stores a profile that was fetched or updated outside the batch loader.
|
|
void put(UserProfile profile) {
|
|
state = {...state, profile.pubkey.toLowerCase(): profile};
|
|
}
|
|
|
|
/// Resolves profiles through verified batch admission.
|
|
///
|
|
/// Unlike [preload], failures propagate to callers whose result requires
|
|
/// profile hydration. Set [refreshCached] for a full revalidation; otherwise
|
|
/// cached profiles remain reusable within this scope.
|
|
Future<Map<String, UserProfile>> resolve(
|
|
List<String> pubkeys, {
|
|
bool refreshCached = false,
|
|
}) async {
|
|
final requested = pubkeys.map((pubkey) => pubkey.toLowerCase()).toSet();
|
|
_trackVerified(requested);
|
|
final toFetch = requested
|
|
.where((pubkey) => refreshCached || !_isCached(pubkey))
|
|
.toList();
|
|
final generation = _generation;
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
for (final batch in _profileQueryBatches(toFetch)) {
|
|
if (!_isCurrent(generation)) {
|
|
throw StateError('Profile request belongs to a retired cache');
|
|
}
|
|
final events = await session.fetchHistory(
|
|
NostrFilters.profilesBatch(batch),
|
|
);
|
|
if (!await _verifyAndMerge(events, generation)) {
|
|
throw StateError('Profile request belongs to a retired cache');
|
|
}
|
|
}
|
|
if (!_isCurrent(generation)) {
|
|
throw StateError('Profile request belongs to a retired cache');
|
|
}
|
|
_unrevalidated.removeAll(toFetch);
|
|
return {for (final pubkey in requested) pubkey: ?state[pubkey]};
|
|
}
|
|
|
|
/// Preload profiles for a list of pubkeys (e.g. channel members).
|
|
/// Returns whether the batch completed successfully.
|
|
Future<bool> preload(List<String> pubkeys) {
|
|
_trackVerified(pubkeys);
|
|
final normalized = pubkeys.map((pk) => pk.toLowerCase()).toSet();
|
|
// Restored profiles being revalidated already render; don't wait on them.
|
|
final alreadyPending = normalized.any(
|
|
(pk) => _pending.contains(pk) && !state.containsKey(pk),
|
|
);
|
|
// A batch already on the wire answers these; asking again would download
|
|
// the same profiles twice.
|
|
final flight = _flightCompleter;
|
|
final awaitsFlight =
|
|
flight != null &&
|
|
normalized.any(
|
|
(pk) => _inFlight.contains(pk) && !state.containsKey(pk),
|
|
);
|
|
final uncached = normalized
|
|
.where(
|
|
(pk) =>
|
|
!state.containsKey(pk) &&
|
|
!_pending.contains(pk) &&
|
|
!_inFlight.contains(pk),
|
|
)
|
|
.toList();
|
|
// Restored profiles already render; revalidate them without making the
|
|
// caller wait on the network.
|
|
normalized.where(_unrevalidated.contains).forEach(_scheduleFetch);
|
|
if (uncached.isEmpty && !alreadyPending) {
|
|
return awaitsFlight ? flight.future : Future.value(true);
|
|
}
|
|
_pending.addAll(uncached);
|
|
final completer = _batchCompleter ??= Completer<bool>();
|
|
_scheduleBatch();
|
|
if (!awaitsFlight) return completer.future;
|
|
return Future.wait([
|
|
flight.future,
|
|
completer.future,
|
|
]).then((results) => results.every((succeeded) => succeeded));
|
|
}
|
|
|
|
/// Force-refresh profiles for identity-sensitive gates.
|
|
///
|
|
/// Unlike [preload], this fetches cached pubkeys too so stale human profiles
|
|
/// cannot be trusted after a verified agent-owner profile was published.
|
|
Future<bool> refresh(List<String> pubkeys) async {
|
|
final normalized = pubkeys
|
|
.map((pubkey) => pubkey.toLowerCase())
|
|
.where((pubkey) => pubkey.isNotEmpty)
|
|
.toSet()
|
|
.toList();
|
|
if (normalized.isEmpty) return true;
|
|
_trackVerified(normalized);
|
|
final generation = _generation;
|
|
try {
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
for (final batch in _profileQueryBatches(normalized)) {
|
|
if (!_isCurrent(generation)) return false;
|
|
final events = await session.fetchHistory(
|
|
NostrFilters.profilesBatch(batch),
|
|
);
|
|
if (!_isCurrent(generation)) return false;
|
|
if (!await _verifyAndMerge(events, generation)) return false;
|
|
}
|
|
_unrevalidated.removeAll(normalized);
|
|
return true;
|
|
} catch (_) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/// Applies a live kind:0 profile event to the cache.
|
|
///
|
|
/// Surfaces that keep a participant-scoped profile subscription can use this
|
|
/// to update names and avatars without discarding the rest of the cache.
|
|
void cacheProfileEvent(NostrEvent event) {
|
|
if (event.kind != 0) return;
|
|
final updated = Map<String, UserProfile>.from(state);
|
|
if (_cacheProfileEvent(event, updated)) state = updated;
|
|
}
|
|
|
|
/// Anyone whose name is needed may also have a NIP-FI verified name.
|
|
void _trackVerified(Iterable<String> pubkeys) =>
|
|
ref.read(verifiedProfilesProvider.notifier).track(pubkeys);
|
|
|
|
void _scheduleFetch(String pubkey) {
|
|
if (_isCached(pubkey) ||
|
|
_pending.contains(pubkey) ||
|
|
_inFlight.contains(pubkey)) {
|
|
return;
|
|
}
|
|
_pending.add(pubkey);
|
|
_batchCompleter ??= Completer<bool>();
|
|
_scheduleBatch();
|
|
}
|
|
|
|
Future<void> _flushPending() async {
|
|
_batchTimer = null;
|
|
if (_pending.isEmpty) return;
|
|
|
|
final pubkeys = _pending.toList();
|
|
_pending.clear();
|
|
final completer = _batchCompleter ?? Completer<bool>();
|
|
_batchCompleter = null;
|
|
_flightCompleter = completer;
|
|
|
|
_flushInFlight = true;
|
|
_inFlight.addAll(pubkeys);
|
|
final generation = _generation;
|
|
var succeeded = false;
|
|
try {
|
|
final session = ref.read(relaySessionProvider.notifier);
|
|
var remaining = pubkeys.length;
|
|
for (final batch in _profileQueryBatches(pubkeys)) {
|
|
if (!_isCurrent(generation)) return;
|
|
final events = await session.fetchHistory(
|
|
NostrFilters.profilesBatch(batch),
|
|
);
|
|
if (!_isCurrent(generation)) return;
|
|
if (!await _verifyAndMerge(events, generation)) return;
|
|
// Confirmed by the relay, whether or not it still has a profile.
|
|
_unrevalidated.removeAll(batch);
|
|
remaining -= batch.length;
|
|
if (remaining == 0) {
|
|
// All requested pages are ready.
|
|
succeeded = true;
|
|
completer.complete(true);
|
|
}
|
|
}
|
|
} catch (_) {
|
|
// Silently fail — non-gating callers will just show pubkeys, or the
|
|
// restored profile, which the next lookup revalidates again.
|
|
} finally {
|
|
if (_isCurrent(generation)) _inFlight.removeAll(pubkeys);
|
|
if (!completer.isCompleted) completer.complete(succeeded);
|
|
if (identical(_flightCompleter, completer)) _flightCompleter = null;
|
|
_flushInFlight = false;
|
|
if (ref.mounted && _pending.isNotEmpty) _scheduleBatch();
|
|
}
|
|
}
|
|
|
|
Iterable<List<String>> _profileQueryBatches(List<String> pubkeys) sync* {
|
|
for (
|
|
var start = 0;
|
|
start < pubkeys.length;
|
|
start += _maximumProfilesPerQuery
|
|
) {
|
|
final end = start + _maximumProfilesPerQuery;
|
|
yield pubkeys.sublist(start, end < pubkeys.length ? end : pubkeys.length);
|
|
}
|
|
}
|
|
|
|
void _scheduleBatch() {
|
|
if (_flushInFlight) return;
|
|
_batchTimer ??= Timer(const Duration(milliseconds: 50), _flushPending);
|
|
}
|
|
|
|
Future<bool> _verifyAndMerge(List<NostrEvent> events, int generation) {
|
|
// Both refresh and preload share one worker slot. Keep this queue across
|
|
// community changes so an old worker cannot overlap a new community's job.
|
|
final result = _verificationQueue.then((_) async {
|
|
if (!_isCurrent(generation)) return false;
|
|
// Match live-cache admission before parsing. In particular, a malformed
|
|
// stale profile must not fail a refresh that previously ignored it.
|
|
final pending = [
|
|
for (final event in events)
|
|
if (event.kind == 0 &&
|
|
_isNewer(event.pubkey.toLowerCase(), event.createdAt, event.id))
|
|
event,
|
|
];
|
|
if (pending.isEmpty) return true;
|
|
final profiles = await ref.read(profileEventBatchParserProvider)(pending);
|
|
if (!_isCurrent(generation)) return false;
|
|
_mergeParsedProfiles(profiles);
|
|
return true;
|
|
});
|
|
// Callers receive the original error; only the sequencing tail recovers so
|
|
// one rejected batch does not poison all future refreshes.
|
|
_verificationQueue = result.then<void>(
|
|
(_) {},
|
|
onError: (Object error, StackTrace stack) {},
|
|
);
|
|
return result;
|
|
}
|
|
|
|
bool _isCurrent(int generation) => ref.mounted && generation == _generation;
|
|
|
|
void _mergeParsedProfiles(List<ParsedProfileEvent> profiles) {
|
|
// Read the current state after the isolate completes: live events and other
|
|
// batches may have installed newer profiles while this batch was parsing.
|
|
final updated = Map<String, UserProfile>.from(state);
|
|
var changed = false;
|
|
for (final parsed in profiles) {
|
|
if (!_isNewer(parsed.profile.pubkey, parsed.createdAt, parsed.eventId)) {
|
|
continue;
|
|
}
|
|
updated[parsed.profile.pubkey] = parsed.profile;
|
|
_profileEventOrders[parsed.profile.pubkey] = (
|
|
createdAt: parsed.createdAt,
|
|
eventId: parsed.eventId,
|
|
);
|
|
changed = true;
|
|
}
|
|
if (changed) state = updated;
|
|
}
|
|
|
|
bool _isNewer(String pubkey, int createdAt, String eventId) {
|
|
final current = _profileEventOrders[pubkey];
|
|
return current == null ||
|
|
createdAt > current.createdAt ||
|
|
(createdAt == current.createdAt &&
|
|
eventId.compareTo(current.eventId) < 0);
|
|
}
|
|
|
|
bool _cacheProfileEvent(NostrEvent event, Map<String, UserProfile> profiles) {
|
|
if (event.kind != 0) return false;
|
|
final pubkey = event.pubkey.toLowerCase();
|
|
if (!_isNewer(pubkey, event.createdAt, event.id)) return false;
|
|
profiles[pubkey] = parseProfileEvent(event).profile;
|
|
_profileEventOrders[pubkey] = (
|
|
createdAt: event.createdAt,
|
|
eventId: event.id,
|
|
);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// Profile history parser, isolated from provider state so batch completion can
|
|
/// be delayed independently of relay delivery.
|
|
final profileEventBatchParserProvider =
|
|
Provider<Future<List<ParsedProfileEvent>> Function(List<NostrEvent>)>(
|
|
(ref) => parseProfileEventBatch,
|
|
);
|
|
|
|
final userCacheProvider =
|
|
NotifierProvider<UserCacheNotifier, Map<String, UserProfile>>(
|
|
UserCacheNotifier.new,
|
|
);
|