Merge pull request #3658 from vitorpamplona/fix/concord-messages-cold-boot

fix: Concord groups missing or slow on the Messages tab after a cold boot
This commit is contained in:
Vitor Pamplona
2026-07-21 14:38:08 -04:00
committed by GitHub
10 changed files with 100 additions and 18 deletions

View File

@@ -38,6 +38,7 @@ import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageRelayListEvent
@@ -170,6 +171,7 @@ private object PrefKeys {
const val LATEST_GEOHASH_LIST = "latestGeohashList"
const val LATEST_EPHEMERAL_LIST = "latestEphemeralChatList"
const val LATEST_RELAY_GROUP_LIST = "latestRelayGroupList"
const val LATEST_CONCORD_LIST = "latestConcordList"
const val LATEST_TRUST_PROVIDER_LIST = "latestTrustProviderList"
const val LATEST_KEY_PACKAGE_RELAY_LIST = "latestKeyPackageRelayList"
const val LATEST_FAVORITE_ALGO_FEEDS_LIST = "latestFavoriteAlgoFeedsList"
@@ -581,6 +583,7 @@ object LocalPreferences {
putOrRemove(PrefKeys.LATEST_GEOHASH_LIST, settings.backupGeohashList)
putOrRemove(PrefKeys.LATEST_EPHEMERAL_LIST, settings.backupEphemeralChatList)
putOrRemove(PrefKeys.LATEST_RELAY_GROUP_LIST, settings.backupRelayGroupList)
putOrRemove(PrefKeys.LATEST_CONCORD_LIST, settings.backupConcordList)
putOrRemove(PrefKeys.LATEST_TRUST_PROVIDER_LIST, settings.backupTrustProviderList)
putOrRemove(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, settings.backupKeyPackageRelayList)
putOrRemove(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, settings.backupFavoriteAlgoFeedsList)
@@ -769,6 +772,7 @@ object LocalPreferences {
val latestGeohashListStr = getString(PrefKeys.LATEST_GEOHASH_LIST, null)
val latestEphemeralListStr = getString(PrefKeys.LATEST_EPHEMERAL_LIST, null)
val latestRelayGroupListStr = getString(PrefKeys.LATEST_RELAY_GROUP_LIST, null)
val latestConcordListStr = getString(PrefKeys.LATEST_CONCORD_LIST, null)
val latestTrustProviderListStr = getString(PrefKeys.LATEST_TRUST_PROVIDER_LIST, null)
val latestKeyPackageRelayListStr = getString(PrefKeys.LATEST_KEY_PACKAGE_RELAY_LIST, null)
val latestFavoriteAlgoFeedsListStr = getString(PrefKeys.LATEST_FAVORITE_ALGO_FEEDS_LIST, null)
@@ -831,6 +835,7 @@ object LocalPreferences {
val latestGeohashList = async { parseEventOrNull<GeohashListEvent>(latestGeohashListStr) }
val latestEphemeralList = async { parseEventOrNull<EphemeralChatListEvent>(latestEphemeralListStr) }
val latestRelayGroupList = async { parseEventOrNull<SimpleGroupListEvent>(latestRelayGroupListStr) }
val latestConcordList = async { parseEventOrNull<ConcordCommunityListEvent>(latestConcordListStr) }
val latestTrustProviderList = async { parseEventOrNull<TrustProviderListEvent>(latestTrustProviderListStr) }
val latestKeyPackageRelayList = async { parseEventOrNull<KeyPackageRelayListEvent>(latestKeyPackageRelayListStr) }
val latestFavoriteAlgoFeedsList = async { parseEventOrNull<FavoriteAlgoFeedsListEvent>(latestFavoriteAlgoFeedsListStr) }
@@ -885,6 +890,7 @@ object LocalPreferences {
val latestGeohashListResolved = latestGeohashList.await()
val latestEphemeralListResolved = latestEphemeralList.await()
val latestRelayGroupListResolved = latestRelayGroupList.await()
val latestConcordListResolved = latestConcordList.await()
val latestTrustProviderListResolved = latestTrustProviderList.await()
val latestKeyPackageRelayListResolved = latestKeyPackageRelayList.await()
val latestFavoriteAlgoFeedsListResolved = latestFavoriteAlgoFeedsList.await()
@@ -981,6 +987,7 @@ object LocalPreferences {
backupGeohashList = latestGeohashListResolved,
backupEphemeralChatList = latestEphemeralListResolved,
backupRelayGroupList = latestRelayGroupListResolved,
backupConcordList = latestConcordListResolved,
backupTrustProviderList = latestTrustProviderListResolved,
backupKeyPackageRelayList = latestKeyPackageRelayListResolved,
backupFavoriteAlgoFeedsList = latestFavoriteAlgoFeedsListResolved,

View File

@@ -77,7 +77,9 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.webBookmarks.dal.WebBookmar
import com.vitorpamplona.amethyst.ui.screen.loggedIn.workouts.dal.WorkoutFeedFilter
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.sample
import kotlinx.coroutines.launch
class AccountFeedContentStates(
@@ -201,6 +203,26 @@ class AccountFeedContentStates(
}
}
// A Concord control-plane fold is what first reveals a community's channels (and what makes
// ConcordCommunitySession.state non-null, without which ChatroomListKnownFeedFilter emits
// nothing at all for that community). None of it flows through LocalCache.newEventBundles,
// so the additive path can't see it: a folded channel reaches the Messages tab only if a
// message for it happens to arrive afterwards. Cold boot therefore shows a *subset* of a
// community's channels, or omits a quiet community entirely, until some unrelated
// invalidation fires. Rebuild on every structural change instead. `revision` bumps only on
// fold/membership/rekey (never a plain message), and sample() coalesces the burst of folds
// that lands as each control plane catches up — the same pairing Account.kt uses to drive
// refreshConcordChannelIndex off this flow.
scope.launch(Dispatchers.IO) {
@OptIn(FlowPreview::class)
account.concordSessions.revision
.drop(1)
.sample(500)
.collect {
dmKnown.invalidateData()
}
}
// Same for the Concord view mode (inline channels vs one row per community).
scope.launch(Dispatchers.IO) {
account.settings.concordViewMode

View File

@@ -62,8 +62,17 @@ enum class ConcordIngestOutcome {
* a chat/reaction/reply/delete message landing, or a duplicate wrap. Must NOT bump the revision. */
NON_STRUCTURAL,
/** Ours and changed structure: a Control-Plane fold (metadata/channels/membership/authority), a
* guestbook membership change, or a buffered base-rekey. Bumps the revision. */
/** Ours and re-folded the Control Plane. The fold republishes [ConcordCommunitySession.state],
* so the session's own state watcher is what bumps the revision — and, because the folded state
* compares by value, only when the fold actually *changed* something. A control wrap that folds
* to an identical state (a prior-epoch wrap that doesn't move the anti-rollback floor, a role
* edition that touches nothing we subscribe on) therefore costs no bump at all. The manager must
* NOT bump on this outcome as well, or every control wrap counts twice. */
STRUCTURAL_FOLD,
/** Ours and changed structure *without* touching [ConcordCommunitySession.state]: a guestbook
* membership change (which republishes `members`) or a buffered base-rekey. No state watcher
* covers these, so the manager bumps the revision directly. */
STRUCTURAL,
;
@@ -145,6 +154,22 @@ class ConcordCommunitySession(
// Deduped inbound wraps.
private val controlWraps = LinkedHashMap<HexKey, Event>()
/**
* Decrypted control editions memoized by wrap id.
*
* Both [refold] and [controlFloorsLocked] fold their WHOLE buffer on every inbound control
* wrap, and turning a wrap into an edition is a NIP-44 open + parse. Re-deriving them each
* time made a cold-boot backfill quadratic in decryptions — one measured boot did ~8.6k opens
* to ingest 93 control wraps for a single community. Memoizing makes it one open per wrap.
*
* Wrap ids are unique and a wrap only ever belongs to one plane (it is routed by `pubKey`), so
* a single id-keyed map is safe across the current and prior-epoch Control Planes even though
* they open under different keys. A wrap that fails to open caches `null` so it is not retried
* on every subsequent fold. The wrap buffers are only ever added to, so this tracks their
* lifetime exactly and needs no separate eviction.
*/
private val editionByWrapId = HashMap<HexKey, ControlEdition?>()
// Prior-epoch Control Plane address -> (wrapId -> wrap). Kept apart from [controlWraps]: these
// never join the live fold, they only produce the anti-rollback floor.
private val historicalControlWraps = HashMap<HexKey, LinkedHashMap<HexKey, Event>>()
@@ -280,7 +305,7 @@ class ConcordCommunitySession(
fun auxStreamKeys(): List<GroupKey> = listOf(guestbookKey, nextBaseRekeyKey)
/** The community's current Control Plane editions — the input a moderation edition chains onto. */
fun controlEditions(): List<ControlEdition> = lock.withLock { ConcordActions.controlEditions(controlWraps.values.toList(), controlPlaneKey) }
fun controlEditions(): List<ControlEdition> = lock.withLock { editionsLocked(controlWraps.values.toList(), controlPlaneKey) }
/** The raw Control Plane wraps buffered so far — the input a Refounding compacts (CORD-06 §3). */
fun controlPlaneWraps(): List<Event> = lock.withLock { controlWraps.values.toList() }
@@ -314,7 +339,7 @@ class ConcordCommunitySession(
if (controlWraps.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
refold()
return ConcordIngestOutcome.STRUCTURAL
return ConcordIngestOutcome.STRUCTURAL_FOLD
}
guestbookAddress -> {
lock.withLock {
@@ -341,7 +366,7 @@ class ConcordCommunitySession(
if (buffer.put(wrap.id, wrap) != null) return ConcordIngestOutcome.NON_STRUCTURAL // dup
}
refold()
return ConcordIngestOutcome.STRUCTURAL
return ConcordIngestOutcome.STRUCTURAL_FOLD
}
val current = lock.withLock { channelKeysByAddress[wrap.pubKey] }
if (current != null) {
@@ -422,7 +447,7 @@ class ConcordCommunitySession(
val wraps = controlWraps.values.toList()
val folded =
ConcordCommunityState.fold(
ConcordActions.controlEditions(wraps, controlPlaneKey),
editionsLocked(wraps, controlPlaneKey),
entry.owner,
controlFloorsLocked(),
)
@@ -455,6 +480,24 @@ class ConcordCommunitySession(
for (channelIdHex in newChannels) reprojectChannel(channelIdHex)
}
/**
* [wraps] opened into editions through [editionByWrapId], so a wrap is only ever decrypted
* once no matter how many folds it participates in. Caller must hold [lock].
*/
private fun editionsLocked(
wraps: Collection<Event>,
planeKey: GroupKey,
): List<ControlEdition> =
wraps.mapNotNull { wrap ->
if (editionByWrapId.containsKey(wrap.id)) {
editionByWrapId[wrap.id]
} else {
val edition = ConcordStreamEnvelope.openOrNull(wrap, planeKey)?.let { ControlEdition.fromRumor(it.rumor) }
editionByWrapId[wrap.id] = edition
edition
}
}
/**
* The per-entity anti-rollback floor: the authority-gated heads of every prior epoch's
* Control Plane we still hold a root for, folded **oldest epoch first** so each epoch is
@@ -472,7 +515,7 @@ class ConcordCommunitySession(
var floors = emptyMap<String, EntityFloor>()
for ((address, keyAtEpoch) in historicalControlKeys.entries.sortedBy { it.value.second }) {
val wraps = historicalControlWraps[address]?.values?.toList() ?: continue
val editions = ConcordActions.controlEditions(wraps, keyAtEpoch.first)
val editions = editionsLocked(wraps, keyAtEpoch.first)
if (editions.isEmpty()) continue
floors = ConcordCommunityState.authorizedHeads(editions, entry.owner, floors)
}

View File

@@ -150,6 +150,9 @@ class ConcordSessionManager(
seenOnRelays: Set<NormalizedRelayUrl> = emptySet(),
): Boolean {
val outcome = registry.ingest(wrap, seenOnRelays)
// Only the planes that change structure *without* republishing `state`. A control-plane
// fold returns STRUCTURAL_FOLD and is bumped by the per-session state watcher instead, which
// (since the folded state compares by value) fires only when the fold genuinely changed.
if (outcome == ConcordIngestOutcome.STRUCTURAL) bumpRevision()
return outcome.claimed
}

View File

@@ -161,7 +161,14 @@ class FeedContentState(
if (noteEvent != null) {
!cacheProvider.hasBeenDeleted(noteEvent)
} else {
false
// An event-less row is a placeholder the filter synthesized for a room
// that has no message yet — a just-joined Concord channel, NIP-29 group,
// Marmot group or geohash cell. It carries no event, so it cannot have
// been deleted, and dropping it here deleted every such row from the
// Messages list the moment ANY kind-5 landed in an unrelated batch. The
// row then stayed gone until the next full rebuild, which is why a quiet
// community looked like it had never loaded at all.
true
}
}.toImmutableList()
}

View File

@@ -108,7 +108,7 @@ class ConcordCommunitySessionTest {
// Feed the genesis control wraps → state folds, channels + membership resolve. A fold is
// STRUCTURAL (it moves the subscription set), so it's allowed to bump the revision.
community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, session.ingest(it)) }
community.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, session.ingest(it)) }
val state = session.state.value
assertEquals("Nostrichs", state?.metadata?.name)
assertTrue(state!!.channels.containsKey(community.generalChannelIdHex))

View File

@@ -70,7 +70,7 @@ class ConcordSessionRegistryTest {
assertTrue(registry.subscribeAddresses().contains(beta.controlPlane.publicKeyHex))
// A genesis control wrap routes to Alpha's session and folds it (STRUCTURAL).
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL, registry.ingest(it)) }
alpha.genesisWraps.forEach { assertEquals(ConcordIngestOutcome.STRUCTURAL_FOLD, registry.ingest(it)) }
val alphaState = registry.sessionFor(alpha.communityIdHex)!!.state.value
assertEquals("Alpha", alphaState?.metadata?.name)

View File

@@ -33,7 +33,7 @@ import com.vitorpamplona.quartz.concord.cord04Roles.RoleEntity
import com.vitorpamplona.quartz.concord.cord04Roles.asFloor
/** A channel id paired with its current folded definition. */
class ConcordChannel(
data class ConcordChannel(
val channelIdHex: String,
val definition: ChannelEntity,
)
@@ -49,7 +49,7 @@ class ConcordChannel(
* "Every member keeps the entire Control Plane in sync — it is small and must
* stay complete." Recompute this whenever the known editions change.
*/
class ConcordCommunityState(
data class ConcordCommunityState(
val ownerPubKey: String,
val metadata: MetadataEntity?,
val channels: Map<String, ConcordChannel>,

View File

@@ -44,7 +44,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
* assigned Role and holds [ConcordPermissions.MANAGE_ROLES]. Cycles that never
* touch the owner can never bootstrap themselves.
*/
class AuthorityResolver private constructor(
data class AuthorityResolver private constructor(
private val ownerLower: String,
private val roles: Map<String, RoleEntity>,
private val memberRoles: Map<String, Set<String>>,

View File

@@ -64,7 +64,7 @@ object ConcordJson {
* drops the role, and with it every authority (grant) that depends on it.
*/
@Serializable
class RoleScope(
data class RoleScope(
val kind: String = "server",
@SerialName("channel_id") val channelId: String? = null,
)
@@ -75,7 +75,7 @@ class RoleScope(
* ranks higher; no role may claim position 0 (reserved for the owner).
*/
@Serializable
class RoleEntity(
data class RoleEntity(
val name: String = "",
val position: Long = 0,
/** u64 permission bitfield as a decimal string. */
@@ -94,7 +94,7 @@ class RoleEntity(
* terminates at the owner (see [AuthorityResolver]).
*/
@Serializable
class GrantEntity(
data class GrantEntity(
val member: String = "",
@SerialName("role_ids") val roleIds: List<String> = emptyList(),
)
@@ -105,7 +105,7 @@ class GrantEntity(
* A [deleted] channel is terminal — its id is never reused.
*/
@Serializable
class ChannelEntity(
data class ChannelEntity(
val name: String = "",
val private: Boolean = false,
val voice: Boolean = false,
@@ -122,7 +122,7 @@ class ChannelEntity(
* community name too.
*/
@Serializable
class MetadataEntity(
data class MetadataEntity(
val name: String = "",
val icon: ImagePointer? = null,
val banner: ImagePointer? = null,