mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge remote-tracking branch 'origin/main' into claude/concord-nip29-invitations-vx2wgj
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
+20
-4
@@ -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
|
||||
}
|
||||
|
||||
+31
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+76
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user