mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(mobile): retry channel-sort startup sync when relay rate-limits cold start
Applies the same startup-sync retry as PR #2995 does for ChannelSectionsManager: on cold start the relay's per-connection quota can be exhausted by the channel-list REQ burst, rejecting the sort manager's history fetch and live subscription with 'rate-limited: quota exceeded'. Both errors were swallowed with no retry, so the custom channel order never loaded. Retry _syncWithRelay with exponential backoff (2s base, 30s cap) until the fetch and live subscription both succeed. A failed fetch still never seed-publishes and the dirty flag is respected on every attempt. Regression test mirrors the sections retry coverage. Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Signed-off-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz>
This commit is contained in:
parent
0c397f5771
commit
83271da8d9
@@ -51,6 +51,12 @@ class ChannelSortManager {
|
||||
void Function()? _unsubscribe;
|
||||
bool _disposed = false;
|
||||
|
||||
/// Base delay for the startup-sync retry backoff. Overridable in tests.
|
||||
final Duration _startupRetryBaseDelay;
|
||||
Timer? _startupRetryTimer;
|
||||
int _startupRetryAttempt = 0;
|
||||
bool _startupFetchSucceeded = false;
|
||||
|
||||
/// Unix seconds of the oldest unpublished local edit; 0 when clean.
|
||||
/// Persisted so unpublished edits survive manager teardown and restarts.
|
||||
int _dirtySince;
|
||||
@@ -67,12 +73,15 @@ class ChannelSortManager {
|
||||
required SignedEventRelay? signedEventRelay,
|
||||
required bool remoteEnabled,
|
||||
required VoidCallback onChanged,
|
||||
@visibleForTesting
|
||||
Duration startupRetryBaseDelay = const Duration(seconds: 2),
|
||||
}) : _storage = ChannelSortStorage(prefs),
|
||||
_crypto = crypto,
|
||||
_relaySession = relaySession,
|
||||
_signedEventRelay = signedEventRelay,
|
||||
_remoteEnabled = remoteEnabled,
|
||||
_onChanged = onChanged,
|
||||
_startupRetryBaseDelay = startupRetryBaseDelay,
|
||||
_store = ChannelSortStorage(prefs).read(pubkey),
|
||||
_dirtySince = ChannelSortStorage(prefs).readDirtySince(pubkey);
|
||||
|
||||
@@ -89,8 +98,20 @@ class ChannelSortManager {
|
||||
return;
|
||||
}
|
||||
|
||||
final foundRemote = await _fetchAndMerge();
|
||||
await _startLiveSubscription();
|
||||
await _syncWithRelay();
|
||||
_onChanged();
|
||||
}
|
||||
|
||||
/// One startup-sync attempt: fetch the remote blob, start the live
|
||||
/// subscription, then reconcile dirty/seed state. Either step can lose a
|
||||
/// transient race on cold start (the relay rate-limits the burst of
|
||||
/// per-channel subscriptions and rejects with `rate-limited: quota
|
||||
/// exceeded`) — retry with backoff instead of silently giving up.
|
||||
Future<void> _syncWithRelay() async {
|
||||
final foundRemote = _startupFetchSucceeded ? true : await _fetchAndMerge();
|
||||
if (foundRemote != null) _startupFetchSucceeded = true;
|
||||
|
||||
final subscribed = _unsubscribe != null || await _startLiveSubscription();
|
||||
|
||||
// Publish-on-reconnect for unpublished local edits, and seed-publish when
|
||||
// the relay confirmed it has no blob but local prefs exist. `foundRemote`
|
||||
@@ -98,13 +119,41 @@ class ChannelSortManager {
|
||||
if (_dirtySince > 0 || (foundRemote == false && _store.groups.isNotEmpty)) {
|
||||
markDirty();
|
||||
}
|
||||
_onChanged();
|
||||
|
||||
if (!_startupFetchSucceeded || !subscribed) {
|
||||
_scheduleStartupRetry();
|
||||
}
|
||||
}
|
||||
|
||||
void _scheduleStartupRetry() {
|
||||
if (_disposed) return;
|
||||
_startupRetryTimer?.cancel();
|
||||
final delayMs = min(
|
||||
_startupRetryBaseDelay.inMilliseconds << min(_startupRetryAttempt, 5),
|
||||
30000,
|
||||
);
|
||||
_startupRetryAttempt++;
|
||||
debugPrint(
|
||||
'[ChannelSortManager] startup sync incomplete; '
|
||||
'retrying in ${delayMs}ms (attempt $_startupRetryAttempt)',
|
||||
);
|
||||
_startupRetryTimer = Timer(Duration(milliseconds: delayMs), () {
|
||||
_startupRetryTimer = null;
|
||||
unawaited(
|
||||
_syncWithRelay().then((_) {
|
||||
if (!_disposed) _onChanged();
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void dispose({bool flushPending = true}) {
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
_startupRetryTimer?.cancel();
|
||||
_startupRetryTimer = null;
|
||||
|
||||
final hadPending = _publishDebounce != null;
|
||||
_publishDebounce?.cancel();
|
||||
_publishDebounce = null;
|
||||
@@ -177,14 +226,16 @@ class ChannelSortManager {
|
||||
return events.any(
|
||||
(e) => e.pubkey == pubkey && e.getTagValue('d') == 'channel-sort',
|
||||
);
|
||||
} catch (_) {
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelSortManager] fetch failed: $error');
|
||||
// Local state remains usable when relay is unavailable.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _startLiveSubscription() async {
|
||||
if (_relaySession == null) return;
|
||||
/// Returns whether the live subscription was established.
|
||||
Future<bool> _startLiveSubscription() async {
|
||||
if (_relaySession == null) return false;
|
||||
try {
|
||||
_unsubscribe = await _relaySession.subscribe(
|
||||
NostrFilter(
|
||||
@@ -197,8 +248,12 @@ class ChannelSortManager {
|
||||
),
|
||||
_handleIncomingEvent,
|
||||
);
|
||||
} catch (_) {
|
||||
// Non-fatal — local state and history still work.
|
||||
return true;
|
||||
} catch (error) {
|
||||
debugPrint('[ChannelSortManager] live subscription failed: $error');
|
||||
// Non-fatal — local state and history still work; retried by the
|
||||
// startup-sync backoff.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -149,6 +149,50 @@ void main() {
|
||||
expect(manager.store.groups.keys, ['channels']);
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
|
||||
test('startup fetch rejected by relay rate limit retries until the remote '
|
||||
'blob is adopted (cold-start regression)', () async {
|
||||
await setUpEnv();
|
||||
final relay = _RateLimitedRelaySession(
|
||||
failuresBeforeSuccess: 2,
|
||||
historyEvents: [
|
||||
sortEvent(groups: {'channels': 'recent'}, createdAt: 100),
|
||||
],
|
||||
);
|
||||
|
||||
final manager = ChannelSortManager(
|
||||
pubkey: keychain.public,
|
||||
prefs: prefs,
|
||||
crypto: crypto,
|
||||
relaySession: relay,
|
||||
signedEventRelay: _RecordingSignedEventRelay(),
|
||||
remoteEnabled: true,
|
||||
onChanged: () {},
|
||||
startupRetryBaseDelay: const Duration(milliseconds: 10),
|
||||
);
|
||||
await manager.initialize();
|
||||
|
||||
expect(manager.sortModeFor('channels'), ChannelSortMode.alpha);
|
||||
|
||||
await _waitUntil(
|
||||
() => manager.sortModeFor('channels') == ChannelSortMode.recent,
|
||||
);
|
||||
expect(relay.subscribeCalls, greaterThan(1));
|
||||
manager.dispose(flushPending: false);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _waitUntil(
|
||||
bool Function() condition, {
|
||||
Duration timeout = const Duration(seconds: 2),
|
||||
}) async {
|
||||
final deadline = DateTime.now().add(timeout);
|
||||
while (!condition()) {
|
||||
if (DateTime.now().isAfter(deadline)) {
|
||||
fail('condition not met within $timeout');
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
||||
}
|
||||
}
|
||||
|
||||
class _SubmittedEvent {
|
||||
@@ -211,3 +255,46 @@ class _FakeRelaySession extends RelaySessionNotifier {
|
||||
void Function(String message)? onClosed,
|
||||
}) async => () {};
|
||||
}
|
||||
|
||||
/// Rejects the first [failuresBeforeSuccess] fetch and subscribe calls with
|
||||
/// the relay's rate-limit error, then succeeds — the Android cold-start
|
||||
/// failure mode where the channel-list REQ burst exhausts the relay's
|
||||
/// per-connection quota before the sort manager gets a turn.
|
||||
class _RateLimitedRelaySession extends RelaySessionNotifier {
|
||||
_RateLimitedRelaySession({
|
||||
required this.failuresBeforeSuccess,
|
||||
this.historyEvents = const [],
|
||||
});
|
||||
|
||||
final int failuresBeforeSuccess;
|
||||
final List<NostrEvent> historyEvents;
|
||||
int fetchCalls = 0;
|
||||
int subscribeCalls = 0;
|
||||
final List<void Function(NostrEvent)> _listeners = [];
|
||||
|
||||
@override
|
||||
Future<List<NostrEvent>> fetchHistory(
|
||||
NostrFilter filter, {
|
||||
Duration timeout = const Duration(seconds: 8),
|
||||
}) async {
|
||||
fetchCalls++;
|
||||
if (fetchCalls <= failuresBeforeSuccess) {
|
||||
throw Exception('rate-limited: quota exceeded; retry in 2s');
|
||||
}
|
||||
return historyEvents;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void Function()> subscribe(
|
||||
NostrFilter filter,
|
||||
void Function(NostrEvent) onEvent, {
|
||||
void Function(String message)? onClosed,
|
||||
}) async {
|
||||
subscribeCalls++;
|
||||
if (subscribeCalls <= failuresBeforeSuccess) {
|
||||
throw Exception('rate-limited: quota exceeded; retry in 1s');
|
||||
}
|
||||
_listeners.add(onEvent);
|
||||
return () => _listeners.remove(onEvent);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user