Compare commits

...

1 Commits

Author SHA1 Message Date
Claude
9773b9cf82 fix: prevent duplicate addressable notes in observeNotes/observeEvents
Replace ConcurrentSkipListSet with ConcurrentHashMap keyed by note.idHex
for identity-based tracking. The previous approach used a sorted set with
a comparator based on mutable event fields (createdAt, event.id). When an
AddressableNote's event was replaced, the tree structure became corrupted:
the old entry remained at a stale position while add() could insert a
duplicate at the new position.

For AddressableNotes, idHex is the stable address (kind:pubkey:dTag), so
ConcurrentHashMap.putIfAbsent naturally deduplicates across event
replacements. For regular Notes, idHex is the unique event id.

Also fix filter() to sort before applying the limit — previously take(limit)
was applied to an unsorted concatenation, potentially discarding the most
recent items.

https://claude.ai/code/session_014UKgckebo3vSJwqfn7TPq6
2026-03-31 02:54:15 +00:00
3 changed files with 70 additions and 33 deletions

View File

@@ -318,16 +318,22 @@ object LocalCache : ILocalCache, ICacheProvider {
}
}
// Sort first, then apply limit to keep the most recent entries
val allMatches = (addressableMatches + noteMatches).toSortedSet(CreatedAtIdHexComparator)
val limit = filter.limit
val limitedSet =
if (limit != null) {
(addressableMatches + noteMatches).take(limit)
} else {
(addressableMatches + noteMatches)
if (limit != null && allMatches.size > limit) {
val result = sortedSetOf(CreatedAtIdHexComparator)
val iterator = allMatches.iterator()
var count = 0
while (iterator.hasNext() && count < limit) {
result.add(iterator.next())
count++
}
return result
}
return limitedSet.toSortedSet(CreatedAtIdHexComparator)
return allMatches
}
fun observeNotes(filter: Filter): Flow<List<Note>> =

View File

@@ -26,20 +26,24 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import java.util.SortedSet
import java.util.concurrent.ConcurrentSkipListSet
import java.util.concurrent.ConcurrentHashMap
/**
* Creates a list of events (regular and addressable)
* that is updated every time a new event that matches
* the filter is received, including addressables.
*
* Uses a ConcurrentHashMap keyed by note.idHex for identity tracking.
* For AddressableNotes, idHex is the stable address (kind:pubkey:dTag),
* so event replacements are correctly detected as updates, not new entries.
*/
class EventListMatchingFilter<T : Event>(
private val filter: Filter,
private val atOnce: (filter: Filter) -> SortedSet<Note>,
private val update: (List<T>) -> Unit,
) : Observable {
// Keeping this here blocks it from being cleared from memory
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
// Keeping this here blocks notes from being cleared from memory
private val trackedNotes = ConcurrentHashMap<String, Note>()
@Suppress("UNCHECKED_CAST")
override fun new(
@@ -47,34 +51,51 @@ class EventListMatchingFilter<T : Event>(
note: Note,
) {
if (event is AddressableEvent && note !is AddressableNote) {
// event update
if (currentResults.contains(note)) {
update(currentResults.mapNotNull { it.event as? T })
// Addressable event arrived through a regular Note path.
// Re-emit if we're tracking this note.
if (trackedNotes.containsKey(note.idHex)) {
emitCurrentResults()
}
return
}
if (filter.match(event)) {
currentResults.add(note)
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
val isNew = trackedNotes.put(note.idHex, note) == null
if (isNew) {
val limit = filter.limit
if (limit != null && trackedNotes.size > limit) {
val sorted = trackedNotes.values.sortedWith(CreatedAtIdHexComparator)
val toRemove = sorted.last()
trackedNotes.remove(toRemove.idHex)
}
}
update(currentResults.mapNotNull { it.event as? T })
// Always emit: new notes need emission, and addressable
// updates (isNew=false) need re-emission with new event content.
emitCurrentResults()
}
}
@Suppress("UNCHECKED_CAST")
override fun remove(note: Note) {
if (currentResults.remove(note)) {
update(currentResults.mapNotNull { it.event as? T })
if (trackedNotes.remove(note.idHex) != null) {
emitCurrentResults()
}
}
@Suppress("UNCHECKED_CAST")
private fun emitCurrentResults() {
update(
trackedNotes.values
.sortedWith(CreatedAtIdHexComparator)
.mapNotNull { it.event as? T },
)
}
@Suppress("UNCHECKED_CAST")
fun init() {
currentResults = ConcurrentSkipListSet(atOnce(filter))
update(currentResults.mapNotNull { it.event as? T })
trackedNotes.clear()
atOnce(filter).associateByTo(trackedNotes) { it.idHex }
emitCurrentResults()
}
}

View File

@@ -26,20 +26,24 @@ import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
import java.util.SortedSet
import java.util.concurrent.ConcurrentSkipListSet
import java.util.concurrent.ConcurrentHashMap
/**
* Creates a list of notes (regular and addressable)
* that only gets updated when a new note appears.
*
* New versions of addressables do not update the list
* New versions of addressables do not update the list.
*
* Uses a ConcurrentHashMap keyed by note.idHex for identity tracking.
* For AddressableNotes, idHex is the stable address (kind:pubkey:dTag),
* so putIfAbsent naturally deduplicates across event replacements.
*/
class NoteListMatchingFilter(
private val filter: Filter,
private val atOnce: (filter: Filter) -> SortedSet<Note>,
private val update: (List<Note>) -> Unit,
) : Observable {
var currentResults: ConcurrentSkipListSet<Note> = ConcurrentSkipListSet(CreatedAtIdHexComparator)
private val trackedNotes = ConcurrentHashMap<String, Note>()
override fun new(
event: Event,
@@ -48,25 +52,31 @@ class NoteListMatchingFilter(
if (event is AddressableEvent && note !is AddressableNote) return
if (filter.match(event)) {
if (currentResults.add(note)) {
// putIfAbsent returns null if the key was absent (note was added).
// For AddressableNotes, idHex is the address, so updates to the
// same addressable are naturally skipped.
if (trackedNotes.putIfAbsent(note.idHex, note) == null) {
val limit = filter.limit
if (limit != null && currentResults.size > limit) {
currentResults.remove(currentResults.last())
if (limit != null && trackedNotes.size > limit) {
val sorted = trackedNotes.values.sortedWith(CreatedAtIdHexComparator)
val toRemove = sorted.last()
trackedNotes.remove(toRemove.idHex)
}
update(currentResults.toList())
update(trackedNotes.values.sortedWith(CreatedAtIdHexComparator))
}
}
}
override fun remove(note: Note) {
if (currentResults.remove(note)) {
update(currentResults.toList())
if (trackedNotes.remove(note.idHex) != null) {
update(trackedNotes.values.sortedWith(CreatedAtIdHexComparator))
}
}
fun init() {
currentResults = ConcurrentSkipListSet(atOnce(filter))
update(currentResults.toList())
trackedNotes.clear()
atOnce(filter).associateByTo(trackedNotes) { it.idHex }
update(trackedNotes.values.sortedWith(CreatedAtIdHexComparator))
}
}