Files
buzz/mobile/lib/features/channels/channel_push_cache.dart
Tom Brow b8aa0233f9 perf(mobile): verify notification cache events in background (#7650)
On iOS, Buzz stores verified profile and channel information for the
notification extension. The extension uses this cache to show sender and
channel names. Loading profiles or channels also updates the cache.

Cache updates currently check event signatures on the UI thread, even
when the caller does not wait for the update. This can delay taps and
screen updates. This change moves those checks to a background worker.
Signature checks, event selection, community boundaries, and the order
of native writes are preserved.

The worker accepts up to eight batches. When it is busy, later profile
requests stay coalesced before fetching; profiles already loaded remain
available immediately. Overlapping channel updates trigger a fresh
joined-channel cache snapshot after the current export finishes, without
delaying or restarting the channel screen. Queue saturation gets up to
five retries per operation. Pending updates get their own recovery
attempt even if the preceding export fails. Recovery fetches
joined-channel data once and retries cache admission without repeating
relay reads. It does not fetch the open-channel directory. Verified
payloads are split into native-sized writes (256 profiles or 512 paired
channels), keeping the same export ordering. Native profile/channel
write failures retry the same verified chunk while retaining the
export's queue position. All chunks share five retries with 7.75 seconds
total backoff; other native errors fail immediately. Terminal failures
are recorded, and later successful writes do not hide them. Captured
batches keep their original community. Pending channel refetches are
retired on a community switch and refreshed when the user returns. The
cache remains best effort; prolonged failures can still prevent
delivery.

Tests through the production APIs reduced UI delay from 27–31 ms to
below 0.3 ms for four profiles, and from 59–60 ms to below 0.3 ms for
four channel and membership pairs. The native payloads were identical.
This does not measure overall screen speed.

Validation: 2,225 mobile tests passed (one skipped), along with 18
focused export tests, 12 recovery tests, 39 native Swift tests, 35
focused profile tests, and static analysis. Production regressions cover
13-entity bursts, invalid newer events, community rebuilds, reentrant
refreshes, and terminal failures. Regression tests fail when producer
recovery controls, terminal-failure draining, or member-only recovery
scope are removed.

---------

Signed-off-by: Tom Brow <tomb@block.xyz>
2026-09-15 23:01:46 +00:00

107 lines
4.0 KiB
Dart

part of 'channels_provider.dart';
extension _ChannelPushCache on ChannelsNotifier {
Future<void> _exportPushCache(
String? communityID,
List<NostrEvent> metadata,
List<NostrEvent> membership,
) async {
if (defaultTargetPlatform != TargetPlatform.iOS || communityID == null) {
return;
}
if (_pushCacheExporting) {
// A refresh can contain different channels. Do not replace its predecessor
// or retain another raw batch: refetch the complete scope after it drains.
_pushCacheDirty = true;
return;
}
_pushCacheExporting = true;
try {
await _pushExport.export(
() => cacheBuzzPushChannelEvents(communityID, metadata, membership),
);
// A failed operation is terminal only for its own input. A refresh that
// arrived meanwhile still owns an independent dirty obligation.
while (_lifecycleRef.mounted && _pushCacheDirty) {
_pushCacheDirty = false;
final session = _lifecycleRef.read(relaySessionProvider.notifier);
final community = _lifecycleRef.read(activeCommunityProvider).value?.id;
final relay = _lifecycleRef.read(relayConfigProvider).baseUrl;
final pubkey = _lifecycleRef.read(myPubkeyProvider);
if (community == null || pubkey == null) return;
bool current() =>
_lifecycleRef.mounted &&
_lifecycleRef.read(activeCommunityProvider).value?.id ==
community &&
_lifecycleRef.read(relayConfigProvider).baseUrl == relay &&
_lifecycleRef.read(myPubkeyProvider) == pubkey &&
identical(
_lifecycleRef.read(relaySessionProvider.notifier),
session,
);
void ensureCurrent() {
if (!current()) throw const _StaleChannelRefresh();
}
// This fetch has no UI writes or channel-refresh generation changes.
// New ordinary refreshes can proceed and mark this snapshot dirty again.
// Start once. Admission retries await this same snapshot rather than
// repeating relay reads while the native export queue remains full.
final snapshot = () async {
try {
if (!current()) return null;
final members = await _fetchChannelMemberships(
session,
pubkey,
ensureCurrent: ensureCurrent,
);
if (!current()) return null;
final ids = members
.map((event) => event.getTagValue('d'))
.whereType<String>()
.toSet()
.toList();
final memberMetadata = ids.isEmpty
? const <NostrEvent>[]
: await session.fetchHistory(NostrFilters.channelMetadata(ids));
if (!current()) return null;
final memberSnapshots = ids.isEmpty
? const <NostrEvent>[]
: await session.fetchHistory(
NostrFilter(
kinds: const [39002],
tags: {'#d': ids},
limit: ids.length,
),
);
if (!current()) return null;
return (
metadata: memberMetadata,
membership: [...members, ...memberSnapshots],
);
} catch (_) {
if (current()) rethrow;
// Retired fetches must neither write nor report an error in the
// replacement scope. Its ordinary refresh owns any new dirty work.
}
return null;
}();
await _pushExport.export(() async {
final events = await snapshot;
if (events == null || !current()) return;
await cacheBuzzPushChannelEvents(
community,
events.metadata,
events.membership,
);
});
// Only new producer input can set dirty again; failure alone never
// schedules another attempt after recovery's bounded retries.
}
} finally {
_pushCacheExporting = false;
}
}
}