mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-07-22 07:48:27 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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>>,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user