feat: sliding window relay pool replaces fixed batch chunks

Instead of waiting for all 50 relays in a batch to finish before
starting the next 50, a Semaphore(MAX_CONCURRENT_RELAYS) keeps the
concurrency window full at all times: the moment one relay is fully
exhausted it releases the semaphore and the next relay starts.

Each relay is handled by downloadFromRelay(), which paginates
independently (until cursor) until the relay returns no events.
supervisorScope ensures a single relay failure does not cancel
the rest of the pool.

Dedup sets and event counter are now thread-safe (ConcurrentHashMap
key sets, AtomicLong) since workers fire concurrently.

SyncState.Running/Paused fields renamed:
  chunkIndex/totalChunks -> relaysCompleted/totalRelays
  nextChunkIndex         -> nextRelayIndex

https://claude.ai/code/session_01U8qF9mK4UBvXsXP1zNMXfX
This commit is contained in:
Claude
2026-03-14 13:44:48 +00:00
parent 50282f32f4
commit 5905926d4a
2 changed files with 117 additions and 115 deletions
@@ -268,13 +268,13 @@ private fun SyncProgressCard(state: EventSyncViewModel.SyncState.Running) {
) { ) {
Column(modifier = Modifier.padding(16.dp)) { Column(modifier = Modifier.padding(16.dp)) {
Text( Text(
text = stringRes(R.string.event_sync_batch_of, state.chunkIndex, state.totalChunks), text = stringRes(R.string.event_sync_batch_of, state.relaysCompleted, state.totalRelays),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
LinearProgressIndicator( LinearProgressIndicator(
progress = { state.chunkIndex.toFloat() / state.totalChunks }, progress = { state.relaysCompleted.toFloat() / state.totalRelays },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -308,8 +308,8 @@ private fun PausedCard(state: EventSyncViewModel.SyncState.Paused) {
text = text =
stringRes( stringRes(
R.string.event_sync_paused_body, R.string.event_sync_paused_body,
state.nextChunkIndex, state.nextRelayIndex,
state.totalChunks, state.totalRelays,
state.eventsSent, state.eventsSent,
), ),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
@@ -35,9 +35,12 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/** /**
* Syncs the user's events across all known relays: * Syncs the user's events across all known relays:
@@ -45,12 +48,15 @@ import java.util.concurrent.atomic.AtomicInteger
* 2. Downloads all events that p-tag the user (non-DM) and sends them to their inbox relays. * 2. Downloads all events that p-tag the user (non-DM) and sends them to their inbox relays.
* 3. Downloads kind-4 and kind-1059 events that p-tag the user and sends them to DM relays. * 3. Downloads kind-4 and kind-1059 events that p-tag the user and sends them to DM relays.
* *
* Relays are processed in batches of [CHUNK_SIZE]. All three filters are sent to every relay * Up to [MAX_CONCURRENT_RELAYS] relays are queried in parallel. As soon as one relay is fully
* in a chunk simultaneously, and events are routed to the appropriate destination by their * exhausted (all pages retrieved) the next relay from the list starts immediately, keeping the
* properties. This removes the need for three sequential passes over the full relay list. * concurrency window full at all times.
*
* Each relay is paginated individually: after EOSE the oldest [Event.createdAt] seen on that
* relay becomes the next `until` cursor, repeating until the relay returns no new events.
* *
* The sync is pausable: calling [cancel] transitions to [SyncState.Paused] so the user can * The sync is pausable: calling [cancel] transitions to [SyncState.Paused] so the user can
* [resume] from the last completed chunk rather than starting over. * [resume] from the last completed relay index rather than starting over.
* *
* Scoped to the AccountViewModel so the sync survives navigation within the same session. * Scoped to the AccountViewModel so the sync survives navigation within the same session.
*/ */
@@ -59,26 +65,26 @@ class EventSyncViewModel(
private val scope: CoroutineScope, private val scope: CoroutineScope,
) { ) {
companion object { companion object {
/** Number of relays queried in each batch. */ /** Maximum number of relays queried at the same time. */
const val CHUNK_SIZE = 50 const val MAX_CONCURRENT_RELAYS = 50
/** How long (ms) to wait for all relays in a chunk to reply before moving on. */ /** How long (ms) to wait for a single relay to reply per page before giving up. */
const val CHUNK_TIMEOUT_MS = 120_000L const val RELAY_TIMEOUT_MS = 30_000L
} }
sealed class SyncState { sealed class SyncState {
object Idle : SyncState() object Idle : SyncState()
data class Running( data class Running(
val chunkIndex: Int, val relaysCompleted: Int,
val totalChunks: Int, val totalRelays: Int,
val eventsSent: Int, val eventsSent: Int,
) : SyncState() ) : SyncState()
/** Cancelled mid-run; can be resumed from [nextChunkIndex]. */ /** Cancelled mid-run; can be resumed from [nextRelayIndex]. */
data class Paused( data class Paused(
val nextChunkIndex: Int, val nextRelayIndex: Int,
val totalChunks: Int, val totalRelays: Int,
val eventsSent: Int, val eventsSent: Int,
) : SyncState() ) : SyncState()
@@ -101,7 +107,7 @@ class EventSyncViewModel(
if (_syncState.value is SyncState.Running) return if (_syncState.value is SyncState.Running) return
syncJob = syncJob =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runSync(startChunkIndex = 0, initialEventsSent = 0) runSync(startRelayIndex = 0, initialEventsSent = 0)
} }
} }
@@ -111,7 +117,7 @@ class EventSyncViewModel(
syncJob = syncJob =
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
runSync( runSync(
startChunkIndex = paused.nextChunkIndex, startRelayIndex = paused.nextRelayIndex,
initialEventsSent = paused.eventsSent, initialEventsSent = paused.eventsSent,
) )
} }
@@ -123,8 +129,8 @@ class EventSyncViewModel(
_syncState.value = _syncState.value =
if (current is SyncState.Running) { if (current is SyncState.Running) {
SyncState.Paused( SyncState.Paused(
nextChunkIndex = current.chunkIndex - 1, // retry the in-progress chunk nextRelayIndex = maxOf(0, current.relaysCompleted - MAX_CONCURRENT_RELAYS),
totalChunks = current.totalChunks, totalRelays = current.totalRelays,
eventsSent = current.eventsSent, eventsSent = current.eventsSent,
) )
} else { } else {
@@ -133,7 +139,7 @@ class EventSyncViewModel(
} }
private suspend fun runSync( private suspend fun runSync(
startChunkIndex: Int, startRelayIndex: Int,
initialEventsSent: Int, initialEventsSent: Int,
) { ) {
val startTime = System.currentTimeMillis() val startTime = System.currentTimeMillis()
@@ -150,16 +156,14 @@ class EventSyncViewModel(
return return
} }
val chunks = allRelays.chunked(CHUNK_SIZE) val relaysToProcess = if (startRelayIndex > 0) allRelays.drop(startRelayIndex) else allRelays
val totalChunks = chunks.size val totalRelays = allRelays.size
val outboxTargets = account.outboxRelays.flow.value val outboxTargets = account.outboxRelays.flow.value
val inboxTargets = account.nip65RelayList.inboxFlow.value val inboxTargets = account.nip65RelayList.inboxFlow.value
val dmTargets = account.dmRelays.flow.value val dmTargets = account.dmRelays.flow.value
// Build the minimal set of filters needed across all three categories. val baseFilters =
// Events are routed to their correct destination(s) by property after retrieval.
val filters =
buildList { buildList {
if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey))) if (outboxTargets.isNotEmpty()) add(Filter(authors = listOf(myPubKey)))
if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) { if (inboxTargets.isNotEmpty() || dmTargets.isNotEmpty()) {
@@ -167,42 +171,38 @@ class EventSyncViewModel(
} }
} }
if (filters.isEmpty()) { if (baseFilters.isEmpty()) {
_syncState.value = _syncState.value =
SyncState.Error("No outbox, inbox, or DM relays configured.") SyncState.Error("No outbox, inbox, or DM relays configured.")
return return
} }
// Deduplication sets — one per destination category. // Thread-safe dedup sets and counter — relay workers run concurrently.
val outboxSent = HashSet<String>(1024) val outboxSent = ConcurrentHashMap.newKeySet<String>()
val inboxSent = HashSet<String>(1024) val inboxSent = ConcurrentHashMap.newKeySet<String>()
val dmSent = HashSet<String>(1024) val dmSent = ConcurrentHashMap.newKeySet<String>()
val totalSent = AtomicLong(initialEventsSent.toLong())
var totalSent = initialEventsSent val relaysCompleted = AtomicInteger(startRelayIndex)
try {
for (i in startChunkIndex until totalChunks) {
if (!isActive) return
_syncState.value = _syncState.value =
SyncState.Running( SyncState.Running(
chunkIndex = i + 1, relaysCompleted = relaysCompleted.get(),
totalChunks = totalChunks, totalRelays = totalRelays,
eventsSent = totalSent, eventsSent = totalSent.get().toInt(),
) )
downloadFromChunk( try {
relays = chunks[i], downloadFromPool(
filters = filters, relays = relaysToProcess,
) { event -> baseFilters = baseFilters,
// Route to outbox if I authored it. onEvent = { event ->
if (event.pubKey == myPubKey && outboxTargets.isNotEmpty()) { if (event.pubKey == myPubKey && outboxTargets.isNotEmpty()) {
if (outboxSent.add(event.id)) { if (outboxSent.add(event.id)) {
account.client.send(event, outboxTargets) account.client.send(event, outboxTargets)
totalSent++ totalSent.incrementAndGet()
} }
} }
// Route p-tagged events to inbox or DM relays.
val pTagsMe = val pTagsMe =
event.tags.any { tag -> event.tags.any { tag ->
tag.size >= 2 && tag[0] == "p" && tag[1] == myPubKey tag.size >= 2 && tag[0] == "p" && tag[1] == myPubKey
@@ -211,31 +211,30 @@ class EventSyncViewModel(
if (event.kind == 4 || event.kind == 1059) { if (event.kind == 4 || event.kind == 1059) {
if (dmTargets.isNotEmpty() && dmSent.add(event.id)) { if (dmTargets.isNotEmpty() && dmSent.add(event.id)) {
account.client.send(event, dmTargets) account.client.send(event, dmTargets)
totalSent++ totalSent.incrementAndGet()
} }
} else { } else {
if (inboxTargets.isNotEmpty() && inboxSent.add(event.id)) { if (inboxTargets.isNotEmpty() && inboxSent.add(event.id)) {
account.client.send(event, inboxTargets) account.client.send(event, inboxTargets)
totalSent++ totalSent.incrementAndGet()
} }
} }
} }
} },
onRelayComplete = {
// Update the running count after the chunk completes. val completed = relaysCompleted.incrementAndGet()
if (isActive) {
_syncState.value = _syncState.value =
SyncState.Running( SyncState.Running(
chunkIndex = i + 1, relaysCompleted = completed,
totalChunks = totalChunks, totalRelays = totalRelays,
eventsSent = totalSent, eventsSent = totalSent.get().toInt(),
)
},
) )
}
}
_syncState.value = _syncState.value =
SyncState.Done( SyncState.Done(
totalEventsSent = totalSent, totalEventsSent = totalSent.get().toInt(),
durationMs = System.currentTimeMillis() - startTime, durationMs = System.currentTimeMillis() - startTime,
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -246,38 +245,61 @@ class EventSyncViewModel(
} }
/** /**
* Sends [baseFilters] to every relay in [relays] simultaneously, then paginates per relay * Maintains a sliding window of up to [MAX_CONCURRENT_RELAYS] active relay workers.
* until each one is exhausted. * As soon as one relay finishes (all pages exhausted), the next relay from [relays]
* * starts immediately — no waiting for an entire batch to drain.
* Many relays impose a hard event-count limit per filter (commonly 500). After EOSE the
* oldest [Event.createdAt] seen on that relay becomes the next `until` cursor, so the
* following request fetches the next page. This repeats until a relay returns no new
* events, at which point it is removed from the active set.
*
* All relays in [relays] are queried in parallel within each pagination round. Only the
* relays that still have more pages participate in subsequent rounds.
*/ */
private suspend fun downloadFromChunk( private suspend fun downloadFromPool(
relays: List<NormalizedRelayUrl>, relays: List<NormalizedRelayUrl>,
baseFilters: List<Filter>, baseFilters: List<Filter>,
onEvent: (Event) -> Unit, onEvent: (Event) -> Unit,
onRelayComplete: () -> Unit,
) { ) {
// `until` cursor per relay; absent = first page (no cursor). val semaphore = Semaphore(MAX_CONCURRENT_RELAYS)
val relayUntil = ConcurrentHashMap<NormalizedRelayUrl, Long>() supervisorScope {
// Relays that can no longer be contacted are skipped immediately. for (relay in relays) {
val deadRelays = ConcurrentHashMap.newKeySet<NormalizedRelayUrl>() if (!isActive) break
semaphore.acquire()
launch {
try {
downloadFromRelay(relay, baseFilters, onEvent)
onRelayComplete()
} finally {
semaphore.release()
}
}
}
}
}
val activeRelays = relays.toMutableList() /**
* Fetches all pages from a single [relay] using paginated `until` cursors.
*
* Sends [baseFilters] and waits for EOSE. If events were returned, the oldest
* [Event.createdAt] minus one becomes the next `until` and the query repeats.
* Stops when EOSE arrives with no new events (relay exhausted) or the relay
* cannot be reached.
*/
private suspend fun downloadFromRelay(
relay: NormalizedRelayUrl,
baseFilters: List<Filter>,
onEvent: (Event) -> Unit,
) {
var until: Long? = null
while (activeRelays.isNotEmpty() && isActive) { while (isActive) {
// Per-relay event count and oldest timestamp for this round. val pageCount = AtomicInteger(0)
val roundCount = ConcurrentHashMap<NormalizedRelayUrl, Int>() val pageMinTs = AtomicLong(Long.MAX_VALUE)
val roundMinTs = ConcurrentHashMap<NormalizedRelayUrl, Long>()
val remaining = AtomicInteger(activeRelays.size)
val done = Channel<Unit>(Channel.CONFLATED) val done = Channel<Unit>(Channel.CONFLATED)
val subId = newSubId() val subId = newSubId()
val filters =
if (until == null) {
baseFilters
} else {
baseFilters.map { it.copy(until = until) }
}
val listener = val listener =
object : IRequestListener { object : IRequestListener {
override fun onEvent( override fun onEvent(
@@ -287,15 +309,15 @@ class EventSyncViewModel(
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
onEvent(event) onEvent(event)
roundCount.merge(relay, 1, Int::plus) pageCount.incrementAndGet()
roundMinTs.merge(relay, event.createdAt, ::minOf) pageMinTs.updateAndGet { minOf(it, event.createdAt) }
} }
override fun onEose( override fun onEose(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
if (remaining.decrementAndGet() <= 0) done.trySend(Unit) done.trySend(Unit)
} }
override fun onClosed( override fun onClosed(
@@ -303,7 +325,7 @@ class EventSyncViewModel(
relay: NormalizedRelayUrl, relay: NormalizedRelayUrl,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
if (remaining.decrementAndGet() <= 0) done.trySend(Unit) done.trySend(Unit)
} }
override fun onCannotConnect( override fun onCannotConnect(
@@ -311,39 +333,19 @@ class EventSyncViewModel(
message: String, message: String,
forFilters: List<Filter>?, forFilters: List<Filter>?,
) { ) {
deadRelays.add(relay) done.trySend(Unit)
if (remaining.decrementAndGet() <= 0) done.trySend(Unit)
} }
} }
// Build per-relay filter list, injecting each relay's current `until` cursor. account.client.openReqSubscription(subId, mapOf(relay to filters), listener)
val filtersMap = withTimeoutOrNull(RELAY_TIMEOUT_MS) { done.receive() }
activeRelays.associateWith { relay ->
val until = relayUntil[relay]
if (until == null) {
baseFilters
} else {
baseFilters.map { it.copy(until = until) }
}
}
account.client.openReqSubscription(subId, filtersMap, listener)
withTimeoutOrNull(CHUNK_TIMEOUT_MS) { done.receive() }
account.client.close(subId) account.client.close(subId)
done.close() done.close()
// Advance cursors for relays that returned events; drop exhausted or dead ones. if (pageCount.get() == 0) break // relay exhausted or unreachable
activeRelays.removeAll { relay ->
when { // Advance cursor: next page starts just before the oldest event seen.
relay in deadRelays -> true until = pageMinTs.get() - 1
(roundCount[relay] ?: 0) == 0 -> true // EOSE with no events = exhausted
else -> {
// Next page starts just before the oldest event seen this round.
relayUntil[relay] = roundMinTs[relay]!! - 1
false
}
}
}
} }
} }
} }