# Nostr Relay Async Patterns Proven coroutine patterns for Nostr relay connections, subscriptions, and event streaming in Amethyst. ## Core Pattern: callbackFlow for Relay Subscriptions ### Pattern: Subscription as Flow **Real implementation from NostrClientStaticReqAsStateFlow.kt:** ```kotlin fun INostrClient.reqAsFlow( relay: NormalizedRelayUrl, filters: List, ): Flow> = callbackFlow { val subId = RandomInstance.randomChars(10) var hasBeenLive = false val eventIds = mutableSetOf() var currentEvents = listOf() val listener = object : IRequestListener { override fun onEvent( event: Event, isLive: Boolean, relay: NormalizedRelayUrl, forFilters: List?, ) { if (event.id !in eventIds) { if (hasBeenLive) { // After EOSE: prepend new events val list = ArrayList(1 + currentEvents.size) list.add(event) list.addAll(currentEvents) currentEvents = list } else { // Before EOSE: append events currentEvents = currentEvents + event } eventIds.add(event.id) trySend(currentEvents) } } override fun onEose( relay: NormalizedRelayUrl, forFilters: List?, ) { hasBeenLive = true } } openReqSubscription(subId, mapOf(relay to filters), listener) awaitClose { close(subId) } } ``` **Key techniques:** 1. **callbackFlow** - Bridge callback API to Flow 2. **Deduplication** - `eventIds` set prevents duplicates 3. **EOSE handling** - Changes insertion strategy (append → prepend) 4. **awaitClose** - Cleanup when flow cancelled 5. **trySend** - Non-blocking emission from callback ## Multi-Relay Patterns ### Pattern: Merge Events from Multiple Relays ```kotlin fun observeFromRelays( relays: List, filters: List ): Flow = relays.map { relay -> client.reqAsFlow(relay, filters) .flatMapConcat { it.asFlow() } }.merge() .distinctBy { it.id } ``` **Explanation:** - Each relay produces `Flow>` - `flatMapConcat` flattens to `Flow` - `merge()` combines all relay flows - `distinctBy` deduplicates across relays ### Pattern: Concurrent Relay Operations with supervisorScope ```kotlin suspend fun subscribeToRelays( relays: List, filters: List ) = supervisorScope { relays.forEach { relay -> launch { relay.subscribe(filters).collect { event -> eventChannel.send(event) } } } } ``` **Why supervisorScope:** - If one relay fails, others continue - All children cancelled when scope cancelled - Structured concurrency maintained ## Backpressure Handling ### Pattern: Buffer with Drop Strategy **For high-frequency event streams:** ```kotlin relayFlow .buffer( capacity = 64, onBufferOverflow = BufferOverflow.DROP_OLDEST ) .collect { event -> processEvent(event) } ``` **Strategy selection:** - `DROP_OLDEST` - For real-time feeds (lose old events OK) - `DROP_LATEST` - For priority queues (lose new events OK) - `SUSPEND` - For critical events (slow down producer) ### Pattern: Conflate for UI Updates ```kotlin val uiEvents: Flow = relayEvents .map { event -> toUiEvent(event) } .conflate() // Skip intermediate, show latest .flowOn(Dispatchers.Default) ``` ## Connection Management ### Pattern: Network Connectivity as Flow **Real implementation from ConnectivityFlow.kt:** ```kotlin @OptIn(FlowPreview::class) val status = callbackFlow { trySend(ConnectivityStatus.StartingService) val connectivityManager = context.getConnectivityManager() val networkCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { connectivityManager.getNetworkCapabilities(network)?.let { trySend(ConnectivityStatus.Active( network.networkHandle, it.isMeteredOrMobileData() )) } } override fun onCapabilitiesChanged( network: Network, networkCapabilities: NetworkCapabilities ) { val isMobile = networkCapabilities.isMeteredOrMobileData() trySend(ConnectivityStatus.Active( network.networkHandle, isMobile )) } override fun onLost(network: Network) { trySend(ConnectivityStatus.Off) } } connectivityManager.registerDefaultNetworkCallback(networkCallback) // Send initial state connectivityManager.activeNetwork?.let { network -> connectivityManager.getNetworkCapabilities(network)?.let { trySend(ConnectivityStatus.Active( network.networkHandle, it.isMeteredOrMobileData() )) } } awaitClose { connectivityManager.unregisterNetworkCallback(networkCallback) trySend(ConnectivityStatus.Off) } } .distinctUntilChanged() .debounce(200) // Stabilize rapid changes .flowOn(Dispatchers.IO) ``` **Key patterns:** 1. **Initial state** - Emit current connectivity immediately 2. **Callback registration** - Register listener in flow body 3. **Cleanup** - Unregister in `awaitClose` 4. **Stabilization** - `debounce(200)` prevents flapping 5. **Deduplication** - `distinctUntilChanged()` skips redundant updates ### Pattern: Reconnect on Connectivity Change ```kotlin connectivityFlow .flatMapLatest { status -> when (status) { is ConnectivityStatus.Active -> { relayPool.connectAll() relayPool.observeEvents() } else -> emptyFlow() } } .collect { event -> handleEvent(event) } ``` ## Exception Handling in Async Operations ### Pattern: CoroutineExceptionHandler + SupervisorJob **Real implementation from PushNotificationReceiverService.kt:** ```kotlin class PushNotificationReceiverService : FirebaseMessagingService() { // Catch all uncaught exceptions val exceptionHandler = CoroutineExceptionHandler { _, throwable -> Log.e("AmethystCoroutine", "Caught exception: ${throwable.message}", throwable) } // Children fail independently, handler catches all private val scope = CoroutineScope( Dispatchers.IO + SupervisorJob() + exceptionHandler ) override fun onMessageReceived(remoteMessage: RemoteMessage) { scope.launch(Dispatchers.IO) { parseMessage(remoteMessage.data)?.let { receiveIfNew(it) } } } override fun onDestroy() { scope.cancel() super.onDestroy() } } ``` **Why this pattern:** - **SupervisorJob** - One failure doesn't cancel others - **ExceptionHandler** - Log exceptions, don't crash - **Scoped lifecycle** - Cancel all on destroy ### Pattern: Retry with Backoff for Relay Connections ```kotlin fun connectWithRetry(relay: Relay): Flow = flow { var attempt = 0 val maxRetries = 5 val baseDelay = 1000L while (attempt < maxRetries) { try { emit(ConnectionStatus.Connecting) relay.connect() emit(ConnectionStatus.Connected) return@flow } catch (e: Exception) { attempt++ emit(ConnectionStatus.Error(e, attempt)) if (attempt < maxRetries) { val delay = baseDelay * (1L shl attempt) // Exponential backoff delay(delay) } } } emit(ConnectionStatus.Failed) } ``` ## Subscription Lifecycle ### Pattern: Auto-Cleanup Subscription ```kotlin @Composable fun ObserveRelayEvents( filters: List, onEvent: (Event) -> Unit ) { val scope = rememberCoroutineScope() DisposableEffect(filters) { val job = scope.launch { relayClient.reqAsFlow(filters).collect { events -> events.forEach { onEvent(it) } } } onDispose { job.cancel() // Cancels flow, triggers awaitClose } } } ``` **Lifecycle:** 1. Composable enters → subscribe 2. filters change → cancel + re-subscribe 3. Composable leaves → cancel + cleanup ### Pattern: Multiple Concurrent Subscriptions ```kotlin fun observeMultipleFeeds( account: Account ): Flow = channelFlow { supervisorScope { // Home feed launch { client.reqAsFlow(filters = homeFeedFilters) .collect { events -> events.forEach { send(it) } } } // Notifications launch { client.reqAsFlow(filters = notificationFilters) .collect { events -> events.forEach { send(it) } } } // DMs launch { client.reqAsFlow(filters = dmFilters) .collect { events -> events.forEach { send(it) } } } } } ``` **Benefits:** - All subscriptions run concurrently - One failure doesn't affect others (supervisorScope) - Single output channel for all events ## Performance Optimization ### Pattern: Shared Upstream for Multiple Collectors ```kotlin class RelayViewModel(private val client: INostrClient) : ViewModel() { val events: SharedFlow = client .reqAsFlow(relay, filters) .flatMapConcat { it.asFlow() } .shareIn( scope = viewModelScope, started = SharingStarted.WhileSubscribed(5000), replay = 0 ) } // Multiple collectors share single relay subscription events.collect { /* UI 1 */ } events.collect { /* UI 2 */ } ``` ### Pattern: Event Deduplication Cache ```kotlin class EventCache { private val seen = mutableSetOf() fun filterNew(events: List): List = events.filter { event -> if (event.id in seen) { false } else { seen.add(event.id) true } } } val deduplicatedEvents = relayEvents .map { events -> cache.filterNew(events) } .filter { it.isNotEmpty() } ``` ## Testing Relay Flows ### Pattern: Test with Fake Relay ```kotlin @Test fun `subscription receives events`() = runTest { val fakeRelay = FakeRelay() val client = NostrClient(fakeRelay) val events = mutableListOf() val job = launch { client.reqAsFlow(relay, filters).collect { list -> events.addAll(list) } } // Simulate relay responses fakeRelay.sendEvent(testEvent1) advanceTimeBy(100) fakeRelay.sendEvent(testEvent2) advanceTimeBy(100) assertEquals(2, events.size) job.cancel() } ``` ## Common Pitfalls ### ❌ Forgetting awaitClose ```kotlin // BAD: Subscription never cleaned up callbackFlow { relay.subscribe(listener) // Missing awaitClose! } ``` ```kotlin // GOOD: Proper cleanup callbackFlow { relay.subscribe(listener) awaitClose { relay.unsubscribe(listener) } } ``` ### ❌ Using GlobalScope ```kotlin // BAD: Unstructured, leaks GlobalScope.launch { relay.connect() } ``` ```kotlin // GOOD: Scoped to lifecycle viewModelScope.launch { relay.connect() } ``` ### ❌ Blocking in Flow Operators ```kotlin // BAD: Blocks collector flow.map { event -> Thread.sleep(1000) // Blocks! process(event) } ``` ```kotlin // GOOD: Use flowOn to offload flow .map { event -> delay(1000) // Suspends, doesn't block process(event) } .flowOn(Dispatchers.Default) ```