mirror of
https://github.com/block/buzz.git
synced 2026-11-07 01:41:06 +00:00
fix(mobile): skip the read-state snapshot when nothing needs publishing (#9093)
Every mobile resume re-syncs read state and then schedules a read-state publish, whether or not anything changed. Each publish starts by downloading the full read-state snapshot (about 1 MB compressed on a heavy account) and only then finds nothing to send. Together with the re-sync's own snapshot, that is two full downloads per resume, and a third when a publish from before backgrounding retries. The publish now checks first, using the same packing it would publish, and returns before the snapshot when nothing is pending and no slot differs from what was last published. When something did change it behaves exactly as before, snapshot included. Today the snapshot fails before downloading on gzip-serving relays, so this only pays off once [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) lands; without this PR, [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) roughly doubles resume download on heavy accounts. It has no merge-order dependency. ### Validation - Added test: `a publish with nothing new skips the snapshot`. It publishes a change (one snapshot), publishes again with nothing new (no snapshot, no event), then publishes another change (snapshot and an event carrying both marks). It fails without the early return. - Existing read-state suite passes. - iPhone 12 mini, Profile build, Home backgrounded 12 s and resumed, all socket bytes received in the 15 s after resume: | Build | Download per resume | Snapshot fetches | | --- | --- | --- | | main (snapshot broken) | 3.07 MB | 0 completed | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) | 6.85 MB | 3 | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) + [#9090 no read-state replay](https://github.com/block/buzz/pull/9090) | 6.08 MB | 3 | | [#9089 read-state gzip fix](https://github.com/block/buzz/pull/9089) + [#9090 no read-state replay](https://github.com/block/buzz/pull/9090) + this | 2.71 MB | 1 | These are single runs on one account with 28 read-state slots, so treat the numbers as indicative. 🤖 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>
This commit is contained in:
@@ -147,6 +147,9 @@ class ReadStateManager {
|
||||
Future<void> _saved = Future.value();
|
||||
bool _saveScheduled = false;
|
||||
Object? syncError;
|
||||
// Whether the latest snapshot read succeeded. A publish that skips its own
|
||||
// snapshot clears [syncError] only on this evidence.
|
||||
bool _lastSnapshotSucceeded = false;
|
||||
final int Function() _now;
|
||||
// Override keys already in this device's own slot, carried unchanged.
|
||||
// See [isOverrideContext].
|
||||
@@ -376,11 +379,13 @@ class ReadStateManager {
|
||||
// Snapshot transport validates completeness and fences retired sessions.
|
||||
final events = await _relaySession!.fetchReadStateSnapshot();
|
||||
if (_disposed || !_servesThisCommunity) return false;
|
||||
_lastSnapshotSucceeded = true;
|
||||
_mergeEvents(events);
|
||||
await _persistLocalState();
|
||||
_onChanged();
|
||||
return true;
|
||||
} catch (error) {
|
||||
_lastSnapshotSucceeded = false;
|
||||
syncError = error;
|
||||
debugPrint('[ReadStateManager] snapshot failed: $error');
|
||||
return false;
|
||||
@@ -546,6 +551,39 @@ class ReadStateManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a publish would send anything: a pending event to retry, or a
|
||||
/// slot whose packed contexts differ from what was last published. Mirrors
|
||||
/// the packing in [_publishOnce] without changing any state.
|
||||
bool _hasUnpublishedState() {
|
||||
if (_pending.isNotEmpty) return true;
|
||||
final primary = <String, int>{..._carriedOverrides};
|
||||
for (final key in overrideGroupFrontierKeys(_carriedOverrides)) {
|
||||
if (_effectiveState[key] case final value?) primary[key] = value;
|
||||
}
|
||||
final horizon = _now() - readStateHorizonSeconds;
|
||||
final List<Map<String, int>> placement;
|
||||
try {
|
||||
placement = packReadStateContexts(
|
||||
{
|
||||
for (final entry in _localContexts.entries)
|
||||
if (entry.value >= horizon) entry.key: entry.value,
|
||||
},
|
||||
clientId: _clientId,
|
||||
previous: _placement,
|
||||
primary: primary,
|
||||
);
|
||||
} on StateError {
|
||||
// Let the real publish surface the capacity error.
|
||||
return true;
|
||||
}
|
||||
if (placement.length > _slots.length) return true;
|
||||
for (var index = 0; index < placement.length; index++) {
|
||||
final published = _published[_slots[index]] ?? const <String, int>{};
|
||||
if (!mapEquals(published, placement[index])) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _publishOnce(
|
||||
SignedEventRelay signedEventRelay, {
|
||||
required bool allowDisposed,
|
||||
@@ -553,10 +591,20 @@ class ReadStateManager {
|
||||
try {
|
||||
if (!_servesThisCommunity || !_ownsJournal) return;
|
||||
await _persistLocalState();
|
||||
// The snapshot below is the whole read state (megabytes on a busy
|
||||
// account), so skip it when there is nothing to send. Each resume
|
||||
// schedules a publish after its re-sync whether or not anything changed.
|
||||
if (!_hasUnpublishedState()) {
|
||||
// Nothing pending and every slot published, so after a good snapshot
|
||||
// this device is in sync, as a full publish would have concluded.
|
||||
if (_lastSnapshotSucceeded) syncError = null;
|
||||
return;
|
||||
}
|
||||
final checkConnection = _relaySession?.captureConnectionFence();
|
||||
var snapshot = <NostrEvent>[];
|
||||
if (_relaySession != null) {
|
||||
final events = await _relaySession.fetchReadStateSnapshot();
|
||||
_lastSnapshotSucceeded = true;
|
||||
if (!_servesThisCommunity ||
|
||||
!_ownsJournal ||
|
||||
(_disposed && !allowDisposed)) {
|
||||
|
||||
@@ -366,6 +366,86 @@ void main() {
|
||||
}
|
||||
});
|
||||
|
||||
test('a publish with nothing new skips the snapshot', () async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keychain = nostr.Keys.generate();
|
||||
final crypto = ReadStateCrypto.tryCreate(
|
||||
nsec: keychain.nsec,
|
||||
pubkey: keychain.public,
|
||||
)!;
|
||||
final relay = _FakeRelaySession();
|
||||
final signer = _FakeSignedEventRelay();
|
||||
final manager = ReadStateManager(
|
||||
pubkey: keychain.public,
|
||||
relayUrl: _relayUrl,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relay,
|
||||
signedEventRelay: signer,
|
||||
remoteEnabled: true,
|
||||
onChanged: () {},
|
||||
now: () => 2000,
|
||||
);
|
||||
|
||||
manager.markContextRead('a', 10);
|
||||
await manager.flush();
|
||||
expect(relay.fetchCount, 1);
|
||||
expect(signer.submitCount, 1);
|
||||
|
||||
// Each resume schedules a publish after its re-sync. With nothing
|
||||
// unpublished it must not download the whole read state again.
|
||||
await manager.flush();
|
||||
expect(relay.fetchCount, 1);
|
||||
expect(signer.submitCount, 1);
|
||||
|
||||
manager.markContextRead('b', 20);
|
||||
await manager.flush();
|
||||
expect(relay.fetchCount, 2);
|
||||
expect(signer.submitCount, 2);
|
||||
final blob = decodeReadStateBlob(crypto.decrypt(signer.contents.last))!;
|
||||
expect(blob.contexts, {'a': 10, 'b': 20});
|
||||
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test(
|
||||
'a skipped publish clears an error a later snapshot recovered',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final keychain = nostr.Keys.generate();
|
||||
final crypto = ReadStateCrypto.tryCreate(
|
||||
nsec: keychain.nsec,
|
||||
pubkey: keychain.public,
|
||||
)!;
|
||||
final relay = _FakeRelaySession()..failNextFetch = true;
|
||||
final manager = ReadStateManager(
|
||||
pubkey: keychain.public,
|
||||
relayUrl: _relayUrl,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relay,
|
||||
signedEventRelay: _FakeSignedEventRelay(),
|
||||
remoteEnabled: true,
|
||||
onChanged: () {},
|
||||
);
|
||||
|
||||
await manager.initialize();
|
||||
expect(manager.syncError, isNotNull);
|
||||
// A publish with nothing to send keeps the error without new evidence.
|
||||
await manager.flush();
|
||||
expect(manager.syncError, isNotNull);
|
||||
|
||||
await manager.reinitializeRemote();
|
||||
await manager.flush();
|
||||
expect(manager.syncError, isNull);
|
||||
expect(relay.fetchCount, 2);
|
||||
|
||||
manager.dispose(flushPending: false);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'retains recent frontiers without a count eviction across restart',
|
||||
() async {
|
||||
|
||||
Reference in New Issue
Block a user