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:
+4
-4
@@ -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,
|
||||||
|
|||||||
+113
-111
@@ -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)
|
||||||
|
|
||||||
|
_syncState.value =
|
||||||
|
SyncState.Running(
|
||||||
|
relaysCompleted = relaysCompleted.get(),
|
||||||
|
totalRelays = totalRelays,
|
||||||
|
eventsSent = totalSent.get().toInt(),
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
for (i in startChunkIndex until totalChunks) {
|
downloadFromPool(
|
||||||
if (!isActive) return
|
relays = relaysToProcess,
|
||||||
|
baseFilters = baseFilters,
|
||||||
_syncState.value =
|
onEvent = { event ->
|
||||||
SyncState.Running(
|
|
||||||
chunkIndex = i + 1,
|
|
||||||
totalChunks = totalChunks,
|
|
||||||
eventsSent = totalSent,
|
|
||||||
)
|
|
||||||
|
|
||||||
downloadFromChunk(
|
|
||||||
relays = chunks[i],
|
|
||||||
filters = filters,
|
|
||||||
) { event ->
|
|
||||||
// Route to outbox if I authored it.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user