fix: add event-level dedup and handleGameAccepted guard in chess lobby
- Dedup incoming events by ID (bounded LRU set of 500) to prevent redundant processing when multiple relays deliver the same event - Filter non-chess kind 30 events early (isStart=false, isMove=false) - Guard handleGameAccepted with recentlyLoadedGames check to prevent N concurrent relay fetches when N move events arrive for same game
This commit is contained in:
+33
-1
@@ -22,9 +22,14 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
|||||||
|
|
||||||
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
import com.vitorpamplona.amethyst.service.relayClient.eoseManagers.PerUniqueIdEoseManager
|
||||||
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
import com.vitorpamplona.amethyst.service.relays.SincePerRelayMap
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
import com.vitorpamplona.quartz.nip01Core.relay.client.pool.RelayBasedFilter
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.IRequestListener
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.client.subscriptions.Subscription
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Query state for chess subscription
|
* Query state for chess subscription
|
||||||
@@ -39,16 +44,43 @@ data class ChessQueryState(
|
|||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sub-assembler that creates the actual relay filters
|
* Sub-assembler that creates the actual relay filters.
|
||||||
|
* Intercepts incoming chess events from relays and forwards them to the ViewModel.
|
||||||
*/
|
*/
|
||||||
class ChessFeedFilterSubAssembler(
|
class ChessFeedFilterSubAssembler(
|
||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
allKeys: () -> Set<ChessQueryState>,
|
allKeys: () -> Set<ChessQueryState>,
|
||||||
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
|
) : PerUniqueIdEoseManager<ChessQueryState, String>(client, allKeys) {
|
||||||
|
var onChessEvent: ((Event) -> Unit)? = null
|
||||||
|
|
||||||
override fun updateFilter(
|
override fun updateFilter(
|
||||||
key: ChessQueryState,
|
key: ChessQueryState,
|
||||||
since: SincePerRelayMap?,
|
since: SincePerRelayMap?,
|
||||||
): List<RelayBasedFilter> = filterChessEvents(key, since)
|
): List<RelayBasedFilter> = filterChessEvents(key, since)
|
||||||
|
|
||||||
override fun id(key: ChessQueryState): String = key.userPubkey
|
override fun id(key: ChessQueryState): String = key.userPubkey
|
||||||
|
|
||||||
|
override fun newSub(key: ChessQueryState): Subscription =
|
||||||
|
requestNewSubscription(
|
||||||
|
object : IRequestListener {
|
||||||
|
override fun onEose(
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEvent(
|
||||||
|
event: Event,
|
||||||
|
isLive: Boolean,
|
||||||
|
relay: NormalizedRelayUrl,
|
||||||
|
forFilters: List<Filter>?,
|
||||||
|
) {
|
||||||
|
if (isLive) {
|
||||||
|
newEose(key, relay, TimeUtils.now(), forFilters)
|
||||||
|
}
|
||||||
|
onChessEvent?.invoke(event)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-4
@@ -21,6 +21,7 @@
|
|||||||
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
package com.vitorpamplona.amethyst.ui.screen.loggedIn.chess.datasource
|
||||||
|
|
||||||
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
import com.vitorpamplona.amethyst.commons.relayClient.composeSubscriptionManagers.ComposeSubscriptionManager
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -29,10 +30,13 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
|
|||||||
class ChessFilterAssembler(
|
class ChessFilterAssembler(
|
||||||
client: INostrClient,
|
client: INostrClient,
|
||||||
) : ComposeSubscriptionManager<ChessQueryState>() {
|
) : ComposeSubscriptionManager<ChessQueryState>() {
|
||||||
val group =
|
private val subAssembler = ChessFeedFilterSubAssembler(client, ::allKeys)
|
||||||
listOf(
|
|
||||||
ChessFeedFilterSubAssembler(client, ::allKeys),
|
val group = listOf(subAssembler)
|
||||||
)
|
|
||||||
|
fun setOnChessEvent(callback: ((Event) -> Unit)?) {
|
||||||
|
subAssembler.onChessEvent = callback
|
||||||
|
}
|
||||||
|
|
||||||
override fun invalidateKeys() = invalidateFilters()
|
override fun invalidateKeys() = invalidateFilters()
|
||||||
|
|
||||||
|
|||||||
+12
-1
@@ -66,8 +66,19 @@ fun ChessSubscription(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wire incoming chess events from relay subscriptions to the ViewModel
|
||||||
|
val chessAssembler = accountViewModel.dataSources().chess
|
||||||
|
DisposableEffect(chessAssembler, chessViewModel) {
|
||||||
|
chessAssembler.setOnChessEvent { event ->
|
||||||
|
chessViewModel.handleIncomingEvent(event)
|
||||||
|
}
|
||||||
|
onDispose {
|
||||||
|
chessAssembler.setOnChessEvent(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Register subscription with Amethyst's subscription system
|
// Register subscription with Amethyst's subscription system
|
||||||
KeyDataSourceSubscription(state, accountViewModel.dataSources().chess)
|
KeyDataSourceSubscription(state, chessAssembler)
|
||||||
|
|
||||||
// Trigger ViewModel refresh when subscription state changes
|
// Trigger ViewModel refresh when subscription state changes
|
||||||
// This fetches challenges from LocalCache after events arrive
|
// This fetches challenges from LocalCache after events arrive
|
||||||
|
|||||||
+43
@@ -128,6 +128,15 @@ class ChessLobbyLogic(
|
|||||||
) {
|
) {
|
||||||
val state = ChessLobbyState(userPubkey, scope)
|
val state = ChessLobbyState(userPubkey, scope)
|
||||||
|
|
||||||
|
// Track when games were last loaded to prevent duplicate fetches
|
||||||
|
// (e.g., discoverUserGames loads a game, then polling immediately re-fetches it)
|
||||||
|
private val recentlyLoadedGames = java.util.concurrent.ConcurrentHashMap<String, Long>()
|
||||||
|
|
||||||
|
// Dedup incoming events (same event delivered by multiple relays)
|
||||||
|
// Bounded LRU: evict oldest when exceeding capacity
|
||||||
|
private val seenEventIds = java.util.Collections.synchronizedSet(LinkedHashSet<String>())
|
||||||
|
private val seenEventIdsMax = 500
|
||||||
|
|
||||||
private val pollingDelegate =
|
private val pollingDelegate =
|
||||||
ChessPollingDelegate(
|
ChessPollingDelegate(
|
||||||
config = pollingConfig,
|
config = pollingConfig,
|
||||||
@@ -188,6 +197,20 @@ class ChessLobbyLogic(
|
|||||||
* Called by platform subscription callbacks for real-time updates.
|
* Called by platform subscription callbacks for real-time updates.
|
||||||
*/
|
*/
|
||||||
fun handleIncomingEvent(event: JesterEvent) {
|
fun handleIncomingEvent(event: JesterEvent) {
|
||||||
|
// Skip non-chess events (kind 30 but not start or move)
|
||||||
|
if (!event.isStartEvent() && !event.isMoveEvent()) return
|
||||||
|
|
||||||
|
// Dedup: skip if we already processed this event ID (multiple relays deliver same event)
|
||||||
|
synchronized(seenEventIds) {
|
||||||
|
if (!seenEventIds.add(event.id)) return
|
||||||
|
if (seenEventIds.size > seenEventIdsMax) {
|
||||||
|
seenEventIds.iterator().let {
|
||||||
|
it.next()
|
||||||
|
it.remove()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Log.d("chessdebug", "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}")
|
Log.d("chessdebug", "[Lobby] handleIncomingEvent: id=${event.id.take(8)}, pubkey=${event.pubKey.take(8)}, isStart=${event.isStartEvent()}, isMove=${event.isMoveEvent()}, createdAt=${event.createdAt}")
|
||||||
when {
|
when {
|
||||||
event.isStartEvent() -> handleStartEvent(event)
|
event.isStartEvent() -> handleStartEvent(event)
|
||||||
@@ -408,6 +431,15 @@ class ChessLobbyLogic(
|
|||||||
* When we detect our challenge was accepted (opponent made first move), load game from relays.
|
* When we detect our challenge was accepted (opponent made first move), load game from relays.
|
||||||
*/
|
*/
|
||||||
fun handleGameAccepted(startEventId: String) {
|
fun handleGameAccepted(startEventId: String) {
|
||||||
|
// Skip if already loaded or in-flight (multiple move events from same game trigger this)
|
||||||
|
val lastLoaded = recentlyLoadedGames[startEventId]
|
||||||
|
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
|
||||||
|
Log.d("chessdebug", "[Lobby] handleGameAccepted: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mark immediately to prevent concurrent launches
|
||||||
|
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||||
|
|
||||||
Log.d("chessdebug", "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays")
|
Log.d("chessdebug", "[Lobby] handleGameAccepted: game ${startEventId.take(8)} - fetching from relays")
|
||||||
scope.launch(Dispatchers.Default) {
|
scope.launch(Dispatchers.Default) {
|
||||||
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
state.setBroadcastStatus(ChessBroadcastStatus.Syncing(0f))
|
||||||
@@ -418,6 +450,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
|
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||||
Log.d("chessdebug", "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}")
|
Log.d("chessdebug", "[Lobby] handleGameAccepted SUCCESS: game ${startEventId.take(8)}, role=${result.reconstructedState.viewerRole}")
|
||||||
state.addActiveGame(startEventId, result.liveState)
|
state.addActiveGame(startEventId, result.liveState)
|
||||||
pollingDelegate.addGameId(startEventId)
|
pollingDelegate.addGameId(startEventId)
|
||||||
@@ -569,6 +602,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
|
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||||
state.addSpectatingGame(startEventId, result.liveState)
|
state.addSpectatingGame(startEventId, result.liveState)
|
||||||
pollingDelegate.addGameId(startEventId)
|
pollingDelegate.addGameId(startEventId)
|
||||||
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
state.setBroadcastStatus(ChessBroadcastStatus.Idle)
|
||||||
@@ -601,6 +635,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
|
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||||
if (result.liveState.isSpectator) {
|
if (result.liveState.isSpectator) {
|
||||||
state.addSpectatingGame(startEventId, result.liveState)
|
state.addSpectatingGame(startEventId, result.liveState)
|
||||||
} else {
|
} else {
|
||||||
@@ -636,6 +671,13 @@ class ChessLobbyLogic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun refreshGame(startEventId: String) {
|
private suspend fun refreshGame(startEventId: String) {
|
||||||
|
// Skip if this game was just loaded (prevents duplicate fetch after discoverUserGames)
|
||||||
|
val lastLoaded = recentlyLoadedGames[startEventId]
|
||||||
|
if (lastLoaded != null && (TimeUtils.now() - lastLoaded) < 10) {
|
||||||
|
Log.d("chessdebug", "[Lobby] refreshGame: SKIPPED game ${startEventId.take(8)} - loaded ${TimeUtils.now() - lastLoaded}s ago")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
Log.d("chessdebug", "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays")
|
Log.d("chessdebug", "[Lobby] refreshGame: fetching game ${startEventId.take(8)} from relays")
|
||||||
val events = fetcher.fetchGameEvents(startEventId)
|
val events = fetcher.fetchGameEvents(startEventId)
|
||||||
|
|
||||||
@@ -831,6 +873,7 @@ class ChessLobbyLogic(
|
|||||||
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadGameResult.Success -> {
|
is LoadGameResult.Success -> {
|
||||||
|
recentlyLoadedGames[startEventId] = TimeUtils.now()
|
||||||
// If the discovered game is already finished, send it straight to completed
|
// If the discovered game is already finished, send it straight to completed
|
||||||
val gameStatus = result.liveState.gameStatus.value
|
val gameStatus = result.liveState.gameStatus.value
|
||||||
if (gameStatus is GameStatus.Finished) {
|
if (gameStatus is GameStatus.Finished) {
|
||||||
|
|||||||
Reference in New Issue
Block a user