mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
perf(mobile): load the channel list's membership-scoped reads together (#8462)
## Summary At startup and on every community switch, the channel list waits on a chain of relay reads, one round trip after another. Four of those reads need only the membership ids: channel metadata, hidden DMs, member snapshots and Huddle starts. Yet each one waited for the read before it. They now go out together, right after the membership list. Everything that depends on them is unchanged: DM participant names still follow metadata, and the latest-message batch still follows the filtered list. - `Future.wait(eagerError: true)` handles every request's failure and rethrows the first one unchanged, as soon as it happens. Before this change a failed read also stopped the chain. Hidden DMs and Huddle starts still fall back to empty on failure, as before. - The refresh fence checks the combined result once, before any state is written. That's the same fence check each read had before. This keeps the duplicate member-snapshot read. Replacing it with the membership list's events would need a relay-contract check first. ## Before and after Setup: - Simulator, debug build. - Local relay with about 120 ms of added round-trip time. - Seeded community with 120 channels. - 5 cold launches each, after one warm-up launch, on the same build with and without this change. - Timed with local probes that are not part of this PR. All 10 launches loaded 120 channels. | | `main` | this PR | |---|---|---| | Connected → channel list ready (median) | 1,233 ms | 715 ms | | Range | 1,154–1,297 ms | 689–747 ms | ## Test A test parks the metadata, hidden-DM and member-snapshot reads at the same time, then checks that all four reads were sent. If any read waits on another, it stays unsent and the test fails. It fails on `main`, where only metadata goes out, and on a version that sends metadata first and the rest after. Three switch tests park before discovery now, so their names say "during a directory refresh" instead of "after directory success". ## Validation - `flutter analyze` - Full mobile suite: 3,535 passed. - The simulator runs above. **Human test:** Wes ran #8456, #8460 and #8462 together on the simulator with his real account and confirmed they work. buzz-review-completed 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Signed-off-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <32a2e2c9d428ee08902cab75d956da2c1d235a22d4766b0dd4138bf6e2e5db1d@buzz.block.builderlab.xyz> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -231,16 +231,54 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
.toSet();
|
||||
_cacheMemberSnapshots(memberships, replaceAll: true);
|
||||
|
||||
// Step 2: pull metadata for joined channels. A user with no memberships
|
||||
// must still continue to directory discovery below.
|
||||
final memberMetas = memberChannelIds.isEmpty
|
||||
? const <NostrEvent>[]
|
||||
: await _fenced(
|
||||
fence,
|
||||
session.fetchHistory(
|
||||
NostrFilters.channelMetadata(memberChannelIds.toList()),
|
||||
// Step 2: everything below the membership list depends only on it, so
|
||||
// send those reads together instead of one round trip after another. A
|
||||
// user with no memberships must still continue to directory discovery.
|
||||
final memberChannelIdList = memberChannelIds.toList();
|
||||
final memberMetasRequest = memberChannelIds.isEmpty
|
||||
? Future.value(const <NostrEvent>[])
|
||||
: session.fetchHistory(
|
||||
NostrFilters.channelMetadata(memberChannelIdList),
|
||||
);
|
||||
final hiddenDmIdsRequest = _fetchHiddenDmIds(session, myPk);
|
||||
// The authoritative membership snapshots are needed to filter Huddle
|
||||
// backing channels. The relay-signed kind:39000 metadata identifies the
|
||||
// relay, not the channel creator; the owner role in kind:39002 is the
|
||||
// canonical creator identity used to reject forged Huddle links.
|
||||
final memberEventsRequest = memberChannelIds.isEmpty
|
||||
? Future.value(const <NostrEvent>[])
|
||||
: session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [39002],
|
||||
tags: {'#d': memberChannelIdList},
|
||||
limit: memberChannelIdList.length,
|
||||
),
|
||||
);
|
||||
final huddleStartsRequest = _fetchHuddleStarts(
|
||||
session,
|
||||
memberChannelIdList,
|
||||
);
|
||||
final (
|
||||
memberMetas,
|
||||
hiddenDmIds,
|
||||
memberEvents,
|
||||
huddleStarts,
|
||||
) = await _fenced(fence, () async {
|
||||
// Future.wait handles every request's failure and rethrows the first
|
||||
// one unchanged, as soon as it happens.
|
||||
await Future.wait(eagerError: true, [
|
||||
memberMetasRequest,
|
||||
hiddenDmIdsRequest,
|
||||
memberEventsRequest,
|
||||
huddleStartsRequest,
|
||||
]);
|
||||
return (
|
||||
await memberMetasRequest,
|
||||
await hiddenDmIdsRequest,
|
||||
await memberEventsRequest,
|
||||
await huddleStartsRequest,
|
||||
);
|
||||
}());
|
||||
|
||||
// Step 3: fetch the open-channel directory. The relay filters this global
|
||||
// kind:39000 query by the caller's access, but the client still rejects
|
||||
@@ -278,31 +316,9 @@ class ChannelsNotifier extends AsyncNotifier<List<Channel>> {
|
||||
myPk,
|
||||
);
|
||||
|
||||
final hiddenDmIds = await _fenced(fence, _fetchHiddenDmIds(session, myPk));
|
||||
// Name resolution may return without a fenced read; check before writing.
|
||||
fence.ensureCurrent();
|
||||
_hiddenDmIds = Set.unmodifiable(hiddenDmIds);
|
||||
// Fetch the authoritative membership snapshots before filtering Huddle
|
||||
// backing channels. The relay-signed kind:39000 metadata identifies the
|
||||
// relay, not the channel creator; the owner role in kind:39002 is the
|
||||
// canonical creator identity used to reject forged Huddle links.
|
||||
final memberCountChannelIds = memberChannelIds.toList();
|
||||
final memberEvents = memberCountChannelIds.isEmpty
|
||||
? const <NostrEvent>[]
|
||||
: await _fenced(
|
||||
fence,
|
||||
session.fetchHistory(
|
||||
NostrFilter(
|
||||
kinds: const [39002],
|
||||
tags: {'#d': memberCountChannelIds},
|
||||
limit: memberCountChannelIds.length,
|
||||
),
|
||||
),
|
||||
);
|
||||
final huddleStarts = memberCountChannelIds.isEmpty
|
||||
? const <NostrEvent>[]
|
||||
: await _fenced(
|
||||
fence,
|
||||
_fetchHuddleStarts(session, memberCountChannelIds),
|
||||
);
|
||||
final huddleBackingIds = huddleBackingChannelIds(
|
||||
huddleStarts,
|
||||
memberEvents,
|
||||
|
||||
@@ -619,7 +619,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'community switch after directory success discards a late failure',
|
||||
'community switch during a directory refresh discards a late failure',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
@@ -630,8 +630,8 @@ void main() {
|
||||
|
||||
expect(await container.read(channelsProvider.future), isEmpty);
|
||||
|
||||
// The directory query succeeds, then the refresh parks on the member-count
|
||||
// query and fails there after the switch. A retired failure must not
|
||||
// The directory refresh parks on the member-count query and fails there
|
||||
// after the switch. A retired failure must not
|
||||
// overwrite the new scope's list or push it into an error state.
|
||||
session.memberships = [_membership(_channelA, myPk)];
|
||||
session.pauseNextMemberCountQuery();
|
||||
@@ -671,7 +671,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'identity switch after directory success discards the late refresh',
|
||||
'identity switch during a directory refresh discards the late refresh',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
@@ -682,7 +682,7 @@ void main() {
|
||||
|
||||
expect(await container.read(channelsProvider.future), isEmpty);
|
||||
|
||||
// Directory success, then the refresh parks on the hidden-DM query while
|
||||
// The directory refresh parks on the hidden-DM query while
|
||||
// the signing identity changes and the new identity finishes its rebuild.
|
||||
session.pauseNextHiddenDmQuery();
|
||||
final staleRefresh = container
|
||||
@@ -710,7 +710,7 @@ void main() {
|
||||
);
|
||||
|
||||
test(
|
||||
'identity switch after directory success discards a late failure',
|
||||
'identity switch during a directory refresh discards a late failure',
|
||||
() async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: const [],
|
||||
@@ -746,6 +746,46 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test('reads after the membership list share one round trip', () async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
metadata: [_meta(id: _channelA, name: 'joined-a')],
|
||||
);
|
||||
final container = _buildContainer(session: session);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
// Park all four reads. Any of them waiting on another would leave that one
|
||||
// unsent.
|
||||
final metadataGate = session.metadataGate = Completer<void>();
|
||||
session.pauseNextHiddenDmQuery();
|
||||
session.pauseNextMemberCountQuery();
|
||||
session.pauseNextHuddleStartQuery();
|
||||
final load = container.read(channelsProvider.future);
|
||||
await _settle();
|
||||
|
||||
// The membership list's own kind:39002 read has no `#d` tag.
|
||||
final sentKinds = {
|
||||
for (final filter in session.historyFilters)
|
||||
if (!filter.kinds.contains(39002) || filter.tags['#d'] != null)
|
||||
...filter.kinds,
|
||||
};
|
||||
expect(
|
||||
sentKinds,
|
||||
containsAll([
|
||||
39000,
|
||||
39002,
|
||||
EventKind.dmVisibility,
|
||||
EventKind.huddleStarted,
|
||||
]),
|
||||
);
|
||||
|
||||
metadataGate.complete();
|
||||
session.resumePausedHiddenDmQuery();
|
||||
session.resumePausedMemberCountQuery();
|
||||
session.resumePausedHuddleStartQuery();
|
||||
expect((await load).map((channel) => channel.id), [_channelA]);
|
||||
});
|
||||
|
||||
test('a newer ordinary refresh owns the installed membership list', () async {
|
||||
final session = _FakeRelaySession(
|
||||
memberships: [_membership(_channelA, myPk)],
|
||||
@@ -2666,6 +2706,9 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
int metadataPageRequestCount = 0;
|
||||
|
||||
final List<NostrFilter> historyFilters = [];
|
||||
|
||||
/// While set, member metadata reads wait for it to complete.
|
||||
Completer<void>? metadataGate;
|
||||
final List<List<NostrFilter>> queryBatches = [];
|
||||
Object? messageBatchError;
|
||||
Object? latestBatchError;
|
||||
@@ -2950,6 +2993,7 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
if (ids == null) {
|
||||
throw StateError('Directory queries must use the HTTP query bridge');
|
||||
}
|
||||
await metadataGate?.future;
|
||||
// Member metadata query — return only matching `d` tags.
|
||||
return metadata.where((e) => ids.contains(e.getTagValue('d'))).toList();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user