Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj

This commit is contained in:
Claude
2026-08-18 19:35:01 +00:00
4 changed files with 134 additions and 18 deletions
@@ -151,21 +151,16 @@ fun painterRes(
@DrawableRes resourceId: Int,
sizeReference: Int,
): Painter {
val cached = iconCache.get(resourceId)
if (cached != null) {
val composition = cached.get(sizeReference)
if (composition != null) {
return composition
}
}
val bySize = iconCache.get(resourceId)
bySize?.get(sizeReference)?.let { return it }
val loaded = painterResource(resourceId)
if (cached == null) {
iconCache.put(resourceId, LruCache<Int, Painter>(10))
} else {
cached.put(sizeReference, loaded)
}
// Store on the FIRST miss as well. This previously created the per-size cache but never
// put `loaded` into it, so a resource had to be requested three times before it could
// ever hit: once to install the (empty) inner cache, once to populate it, once to read it.
val sizes = bySize ?: LruCache<Int, Painter>(10).also { iconCache.put(resourceId, it) }
sizes.put(sizeReference, loaded)
return loaded
}
@@ -560,11 +560,27 @@ object ConcordActions {
fun guestbookMembers(
wraps: List<Event>,
guestbook: GroupKey,
): Set<HexKey> {
): Set<HexKey> = projectGuestbook(wraps.mapNotNull { guestbookEntry(it, guestbook) })
/**
* Opens a single guestbook [wrap] into its entry, or null when it doesn't belong to
* [guestbook] or isn't a guestbook rumor.
*
* Split out of [guestbookMembers] so a caller holding a growing wrap buffer can memoize the
* open per wrap id: opening is the expensive half (two NIP-44 decrypts plus the wrap and seal
* signature verifies), while [projectGuestbook] over the already-opened entries is trivial.
* Re-projecting a buffer of n wraps on every arrival without that memo is quadratic in
* decryptions — see [ConcordCommunitySession]'s guestbook cache.
*/
fun guestbookEntry(
wrap: Event,
guestbook: GroupKey,
): GuestbookEntry? = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor?.let { Guestbook.parse(it) }
/** Last-writer-wins projection of already-opened [entries] down to the JOINed member set. */
fun projectGuestbook(entries: Collection<GuestbookEntry>): Set<HexKey> {
val latest = HashMap<HexKey, GuestbookEntry>()
for (wrap in wraps) {
val rumor = ConcordStreamEnvelope.openOrNull(wrap, guestbook)?.rumor ?: continue
val entry = Guestbook.parse(rumor) ?: continue
for (entry in entries) {
val prev = latest[entry.member.lowercase()]
if (prev == null || entry.createdAt > prev.createdAt) latest[entry.member.lowercase()] = entry
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.amethyst.commons.util.KmpLock
import com.vitorpamplona.amethyst.commons.util.withLock
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityState
import com.vitorpamplona.quartz.concord.cord02Community.GuestbookEntry
import com.vitorpamplona.quartz.concord.cord03Channels.ChannelChat
import com.vitorpamplona.quartz.concord.cord04Roles.ControlEdition
import com.vitorpamplona.quartz.concord.cord04Roles.EditionFold
@@ -193,6 +194,26 @@ class ConcordCommunitySession(
*/
private val editionByWrapId = HashMap<HexKey, ControlEdition?>()
/**
* Guestbook wraps opened into entries, memoized by wrap id — the guestbook analogue of
* [editionByWrapId]. [refoldGuestbook] runs on *every* arriving guestbook wrap and re-projects
* the whole buffer, so without this the nth arrival re-opens all n wraps and a boot costs
* O(n^2) envelope opens (each = two NIP-44 decrypts + two signature verifies). Measured on a
* cold start: 6,229 opens over 448 distinct wraps, ~all of the app's NIP-44 traffic.
*
* Safe to key on wrap id alone: [guestbookKey] is derived once at construction from the
* session's epoch and never rotates in place (a rekey builds a new session).
*/
private val guestbookEntryByWrapId = HashMap<HexKey, GuestbookEntry?>()
/**
* Envelope opens [refoldGuestbook] actually performed (cache misses). Exposed so a test can
* assert the fold stays linear in arrivals; a regression to re-opening the buffer shows up here
* as O(n^2) long before it shows up as a slow boot.
*/
internal var guestbookOpens = 0
private set
// 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>>()
@@ -607,8 +628,16 @@ class ConcordCommunitySession(
private fun refoldGuestbook() {
lock.withLock {
val wraps = guestbookWraps.values.toList()
_members.value = ConcordActions.guestbookMembers(wraps, guestbookKey)
val entries =
guestbookWraps.values.mapNotNull { wrap ->
if (guestbookEntryByWrapId.containsKey(wrap.id)) {
guestbookEntryByWrapId[wrap.id]
} else {
guestbookOpens++
ConcordActions.guestbookEntry(wrap, guestbookKey).also { guestbookEntryByWrapId[wrap.id] = it }
}
}
_members.value = ConcordActions.projectGuestbook(entries)
}
}
@@ -0,0 +1,76 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.model.concord
import com.vitorpamplona.amethyst.commons.actions.ConcordActions
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityFactory
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEntry
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* The guestbook re-folds on every arriving wrap, so opening the buffer each time is quadratic in
* NIP-44 decrypts (plus two signature verifies apiece). A cold start measured 6,229 envelope opens
* over 448 distinct wraps — ~13x redundant, and effectively all of the app's NIP-44 traffic.
*/
class ConcordGuestbookFoldTest {
private val owner = NostrSignerInternal(KeyPair())
@Test
fun opensEachGuestbookWrapOnceAcrossSequentialArrivals() =
runTest {
val community = ConcordCommunityFactory.create(owner, "Nostrichs", createdAt = 1L, relays = listOf("wss://r.example"))
val entry =
ConcordCommunityListEntry(
id = community.communityIdHex,
owner = community.ownerPubKey,
ownerSalt = community.ownerSalt.toHexKey(),
root = community.communityRoot.toHexKey(),
rootEpoch = community.rootEpoch,
controlPk = community.controlPkHex,
controlRoot = community.controlRoot.toHexKey(),
relays = listOf("wss://r.example"),
name = "Nostrichs",
)
val session = ConcordCommunitySession(entry, owner.pubKey) { _, _, _, _ -> }
community.genesisWraps.forEach { session.ingest(it) }
val guestbook = ConcordActions.guestbookPlane(community.communityRoot, community.communityId, community.rootEpoch)
val members = List(12) { NostrSignerInternal(KeyPair()) }
val joins = members.mapIndexed { i, m -> ConcordActions.buildGuestbookJoin(m, guestbook, createdAt = 2L + i) }
// Arrivals land one at a time, exactly as the relay delivers them.
joins.forEach { session.ingest(it) }
// Linear, not 12*13/2 = 78.
assertEquals(joins.size, session.guestbookOpens, "guestbook wraps were re-decrypted on later folds")
assertEquals(members.mapTo(HashSet()) { it.pubKey.lowercase() }, session.members.value)
// A duplicate delivery re-folds nothing and opens nothing.
session.ingest(joins.first())
assertEquals(joins.size, session.guestbookOpens)
assertEquals(members.mapTo(HashSet()) { it.pubKey.lowercase() }, session.members.value)
}
}