From 683f993c7b931df655f2265e8bd4f472389fe7d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 00:00:24 +0000 Subject: [PATCH 01/12] feat: propagate deletions (NIP-09/62) across negentropy sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-77 reconciles by event id over the content filter, so a scoped sync (`--kind 1`) never carries the kind-5/62 that deletes one of those notes: the deletion stays stuck on whichever side issued it while the target lives on forever on the other. Add a deletion side-channel that reconciles kinds 5 & 62 on their own, independent of the content filter. quartz: NostrClientDeletionSyncExt — DELETION_PROPAGATION_KINDS, Filter.excludesDeletionKinds()/deletionSideChannelFilter() (kinds 5/62 scoped to the same authors, no time window since a deletion's created_at is not its target's), shouldPropagateDeletionUp() (kind-5 always; kind-62 only to a relay it targets, honoring the vanish's declared relays), and negentropyPropagateDeletions() — one bidirectional reconcile that streams have→upload and need→download. amy sync: run the side-channel bidirectionally regardless of --up/--down whenever the filter excludes 5/62; emits deletions_{need,have,downloaded, uploaded}; --no-sync-deletions opts out. geode MirrorWorker: thread a per-upstream deletionScope through both catch-up phases and both live subs (down + up), in the mirror's configured direction; relax down containment to accept in-scope deletions, gate kind-62 pushes by target relay, and carry the deletion filter on re-subscribe so a reconnect never drops it. Tests: DeletionSyncTest (up/down propagation + filter/vanish-gate units) and MirrorDeletionSyncTest (a kind-scoped down mirror still removes the note). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../amethyst/cli/commands/SyncCommand.kt | 46 +++++ .../geode/mirror/MirrorWorker.kt | 148 ++++++++----- .../vitorpamplona/geode/DeletionSyncTest.kt | 194 ++++++++++++++++++ .../geode/mirror/MirrorDeletionSyncTest.kt | 114 ++++++++++ .../accessories/NostrClientDeletionSyncExt.kt | 134 ++++++++++++ 5 files changed, 582 insertions(+), 54 deletions(-) create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 9f878186d0..c7ad0ce781 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -27,6 +27,9 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyPropagateDeletions import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -54,6 +57,16 @@ import java.util.concurrent.atomic.AtomicInteger * Pass both for a full bidirectional sync. The filter flags are the same as * `fetch`/`subscribe`; an empty filter reconciles the whole store. * + * Deletions ride a side-channel. NIP-77 reconciles by id over the content + * filter, so a scoped sync (`--kind 1`) would never carry the kind-5/62 that + * deletes one of those notes — the deletion would be stuck on whichever side + * issued it. So whenever the filter pins `kinds` and excludes 5/62, a second + * reconcile over `{kinds:[5,62], authors:}` runs **both directions + * regardless of --up/--down** ([negentropyPropagateDeletions]): local deletions + * are pushed up so the relay applies them, and the relay's deletions are pulled + * down so the local store applies them. A kind-62 vanish is only pushed to a + * relay it actually targets. Pass `--no-sync-deletions` to opt out. + * * Both directions are pipelined with the reconcile: need-id batches feed * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single * uploader, so downloads and uploads overlap the remaining reconcile rounds @@ -93,6 +106,7 @@ object SyncCommand { // Default direction is download; --up adds upload. val up = args.bool("up") val down = args.bool("down") || !up + val syncDeletions = !args.bool("no-sync-deletions") val filter = RawEventSupport.buildFilter(args) Context.open(dataDir).use { ctx -> @@ -160,6 +174,34 @@ object SyncCommand { return Output.error("sync_error", e.message ?: "negentropy sync failed") } + // Deletion side-channel: propagate kind 5/62 both ways, independent of + // the content filter, so a scoped sync still carries the deletions that + // apply to it. No-op (returns null) when the filter already covers 5/62. + val deletionsDown = AtomicInteger(0) + val deletionsUp = AtomicInteger(0) + val deletionResult = + if (syncDeletions && filter.excludesDeletionKinds()) { + try { + val localDeletions = ctx.store.query(filter.deletionSideChannelFilter()) + ctx.client.negentropyPropagateDeletions( + relay = relay, + contentFilter = filter, + localDeletions = localDeletions, + idleTimeoutMs = timeoutMs, + download = { batch -> + deletionsDown.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) + }, + upload = { event -> + if (ctx.publish(event, setOf(relay)).values.any { it }) deletionsUp.incrementAndGet() + }, + ) + } catch (e: NegentropySyncException) { + return Output.error("sync_error", e.message ?: "deletion sync failed") + } + } else { + null + } + Output.emit( mapOf( "relay" to relay.url, @@ -169,6 +211,10 @@ object SyncCommand { "have" to result.haveCount, "downloaded" to downloaded.get(), "uploaded" to uploaded.get(), + "deletions_need" to (deletionResult?.needCount ?: 0), + "deletions_have" to (deletionResult?.haveCount ?: 0), + "deletions_downloaded" to deletionsDown.get(), + "deletions_uploaded" to deletionsUp.get(), ), ) return 0 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index 62936d17fe..8abf263d95 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -23,8 +23,11 @@ package com.vitorpamplona.geode.mirror import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.shouldPropagateDeletionUp import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -239,17 +242,24 @@ class MirrorWorker( val listener: SubscriptionListener, initialSince: Long, val watermark: AtomicLong, + // The deletion side-channel filter (kinds 5/62), when the operator scope + // excludes them. Carried here so it rides every re-subscribe too — else a + // reconnect would silently drop live deletions. + val deletionScope: Filter?, ) { @Volatile var issuedSince: Long = initialSince + /** Content scope plus, when in force, the deletion side-channel — both at [since]. */ + fun filtersFrom(since: Long): List = listOfNotNull(scopedBase.copy(since = since), deletionScope?.copy(since = since)) + fun advanceSinceOnReconnect() { val candidate = watermark.get() - WATERMARK_OVERLAP_SECS if (candidate > issuedSince) { issuedSince = candidate client.subscribe( subId = subId, - filters = mapOf(up.url to listOf(scopedBase.copy(since = candidate))), + filters = mapOf(up.url to filtersFrom(candidate)), listener = listener, ) sinceAdvances.incrementAndGet() @@ -330,6 +340,15 @@ class MirrorWorker( val scopedBase = (up.filter ?: Filter()).copy(since = null, limit = null) val initialSince = since - up.backfillSeconds + // Deletion side-channel scope. NIP-77 (and the live REQ) reconcile by + // id over the operator filter, so a kind-scoped mirror (`kinds:[1]`) + // would drop the kind-5/62 that deletes one of those notes — the + // deletion would never reach the other side. When the operator filter + // excludes 5/62, mirror them on their own (same authors), in the + // mirror's configured direction. `null` when the filter already covers + // deletions (unscoped, or 5/62 explicitly listed) — nothing extra to do. + val deletionScope = up.filter?.takeIf { it.excludesDeletionKinds() }?.deletionSideChannelFilter() + // strfry's two-phase model, both directions (`strfry sync --dir // both` + the live router): a one-shot NIP-77 "sync" closes the // historical [initialSince, now] gap — down pulls what the upstream @@ -344,16 +363,16 @@ class MirrorWorker( val upLiveSince = if (catchUpUp) since else initialSince if (up.direction != MirrorDirection.UP) { - downSubs += startDown(i, up, scopedBase, downLiveSince, exchanged) + downSubs += startDown(i, up, scopedBase, downLiveSince, exchanged, deletionScope) } if (up.direction != MirrorDirection.DOWN) { - startUp(up, scopedBase.copy(since = upLiveSince), exchanged) + startUp(up, scopedBase.copy(since = upLiveSince), exchanged, deletionScope?.copy(since = upLiveSince)) } if (catchUpDown) { - scope.launch { runCatchUpDown(up, scopedBase, initialSince, since) } + scope.launch { runCatchUpDown(up, scopedBase, initialSince, since, deletionScope) } } if (catchUpUp) { - scope.launch { runCatchUpUp(up, scopedBase, initialSince, since, exchanged) } + scope.launch { runCatchUpUp(up, scopedBase, initialSince, since, exchanged, deletionScope) } } } client.connect() @@ -411,12 +430,14 @@ class MirrorWorker( scopedBase: Filter, initialSince: Long, until: Long, + deletionScope: Filter?, ) { val catchUpFilter = scopedBase.copy(since = initialSince, until = until) - // Reconcile against what we already hold in this window → download only - // the diff (like `strfry sync`). No store wired → empty local set → the - // whole window is downloaded and the store's unique-id constraint dedups. - val localEntries = store?.snapshotIdsForNegentropy(listOf(catchUpFilter)) ?: emptyList() + + // Even a trusted upstream may only inject events inside the declared + // scope — plus the deletion side-channel scope when one is in force, so a + // kind-scoped mirror still accepts the kind-5/62 that apply to it. + fun inScope(event: Event): Boolean = up.filter == null || up.filter.match(event) || deletionScope?.match(event) == true // Bounded hand-off → one ingest consumer. `onEvent` can't suspend, so it // blocks here when the sink falls behind; because negentropySyncOrFetch's @@ -442,26 +463,32 @@ class MirrorWorker( } } - try { + // Reconciles one filter against what we already hold → downloads only the + // diff (like `strfry sync`). No store wired → empty local set → the whole + // set is downloaded and the store's unique-id constraint dedups. + suspend fun pull(filter: Filter) { + val localEntries = store?.snapshotIdsForNegentropy(listOf(filter)) ?: emptyList() val result = client.negentropySyncOrFetch( relay = up.url, - filter = catchUpFilter, + filter = filter, localEntries = localEntries, onEvent = { event -> - // Same containment as the live path: even a trusted - // upstream may only inject events inside the declared scope. - if (up.filter == null || up.filter.match(event)) { - handoff.trySendBlocking(event) - } else { - filtered.incrementAndGet() - } + if (inScope(event)) handoff.trySendBlocking(event) else filtered.incrementAndGet() }, ) Log.i("MirrorWorker") { val how = if (result.pagedFallback) "paged REQ (upstream has no NIP-77)" else "negentropy" "catch-up from ${up.url.url}: ${result.downloaded} events via $how" } + } + + try { + pull(catchUpFilter) + // Deletions carry no time window: a deletion's created_at is when it + // was issued, not when its target was, so the whole deletion set for + // the scope is reconciled rather than the [initialSince, until] window. + if (deletionScope != null) pull(deletionScope) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -489,65 +516,71 @@ class MirrorWorker( initialSince: Long, until: Long, exchanged: RecentIds?, + deletionScope: Filter?, ) { val localStore = store ?: return val catchUpFilter = scopedBase.copy(since = initialSince, until = until) - // Our local set for this window is fixed; each round reconciles it - // against the upstream, which grows as we push, so the `have` diff - // shrinks to zero. - val localEntries = localStore.snapshotIdsForNegentropy(listOf(catchUpFilter)) - try { + + // Reconcile [filter] against the upstream and PUSH the events we hold that + // it lacks (the `have` ids), re-reconciling each round until the diff is + // empty — the reconcile is the delivery check, so the push converges to + // lossless. [publishable] gates which local events actually go: scope + // containment for content, and per-relay vanish targeting for kind-62 (a + // vanish is only sent to a relay it names). + suspend fun pushUp( + filter: Filter, + label: String, + publishable: (Event) -> Boolean, + ) { + // Our local set for this window is fixed; each round reconciles it + // against the upstream, which grows as we push, so the diff shrinks. + val localEntries = localStore.snapshotIdsForNegentropy(listOf(filter)) var round = 0 while (round < MAX_UP_SYNC_ROUNDS) { - // Reconcile and PUBLISH the `have` ids (events we hold the - // upstream lacks) as each batch streams in — never materialising - // the full diff. On a large window the id list is millions of - // entries; the streaming reconcile keeps memory at one batch and - // still back-pressures the relay because publishing suspends the - // round. The `need` direction is the down catch-up's job, so its - // ids are discarded (not accumulated) here. + // Stream the `have` batches and publish as they arrive — never + // materialising the full diff. Publishing suspends the round, so + // the relay is back-pressured. The `need` direction is the down + // catch-up's job, so its ids are discarded here. var haveCount = 0 var pushed = 0 client.negentropyReconcile( relay = up.url, - filter = catchUpFilter, + filter = filter, localEntries = localEntries, batchSize = HAVE_FETCH_BATCH, onHaveIds = { batch -> haveCount += batch.size for (event in localStore.query(Filter(ids = batch))) { - // Scope containment: a scoped upstream only receives - // in-scope events. Echo suppression: record the id so - // a BOTH mirror doesn't re-ingest its own push on the - // down sub (but always re-publish — a straggler stays - // in `exchanged` yet still needs delivering). - if (up.filter != null && !up.filter.match(event)) continue + if (!publishable(event)) continue + // Echo suppression: record the id so a BOTH mirror + // doesn't re-ingest its own push on the down sub (but + // always re-publish — a straggler stays in `exchanged` + // yet still needs delivering). exchanged?.add(event.id) client.publish(event, setOf(up.url)) pushed++ } - // Pace the outbox so a batch drains before the next. - delay(UP_PUBLISH_PACING_MS) + delay(UP_PUBLISH_PACING_MS) // pace the outbox }, onNeedIds = { }, ) if (haveCount == 0) { - Log.i("MirrorWorker") { "up catch-up to ${up.url.url}: converged after $round round(s)" } + Log.i("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: converged after $round round(s)" } return } - // `client.publish`'s outbox is best-effort under a bulk burst - // (each publish also churns a reconnect), so instead of trusting - // one pass we re-reconcile next round and re-push only what didn't - // land — the reconcile is the delivery check, so the push - // converges to lossless. sentUp.addAndGet(pushed.toLong()) - Log.i("MirrorWorker") { "up catch-up to ${up.url.url}: round $round pushed $pushed (had $haveCount to go)" } + Log.i("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: round $round pushed $pushed (had $haveCount to go)" } round++ - // Let the upstream ingest + OK before the next reconcile, so the - // diff reflects what actually landed rather than what's in flight. - delay(UP_SYNC_SETTLE_MS) + delay(UP_SYNC_SETTLE_MS) // let the upstream ingest + OK before re-checking + } + Log.w("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: did not fully converge in $MAX_UP_SYNC_ROUNDS rounds (live push continues)" } + } + + try { + pushUp(catchUpFilter, "content") { event -> up.filter == null || up.filter.match(event) } + if (deletionScope != null) { + pushUp(deletionScope, "deletions") { event -> shouldPropagateDeletionUp(event, up.url) } } - Log.w("MirrorWorker") { "up catch-up to ${up.url.url}: did not fully converge in $MAX_UP_SYNC_ROUNDS rounds (live push continues)" } } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -562,6 +595,7 @@ class MirrorWorker( scopedBase: Filter, initialSince: Long, exchanged: RecentIds?, + deletionScope: Filter?, ): DownSub { // watermark tracks the newest created_at ingested from this // upstream; seeded at initialSince so a still-catching-up @@ -593,7 +627,7 @@ class MirrorWorker( // upstream can only inject events the operator // declared — the REQ shapes what we ask for, this // shapes what we accept. - if (up.filter != null && !up.filter.match(event)) { + if (up.filter != null && !up.filter.match(event) && deletionScope?.match(event) != true) { filtered.incrementAndGet() Log.d("MirrorWorker") { "out-of-scope from ${relay.url}: ${event.id}" } return @@ -616,12 +650,13 @@ class MirrorWorker( } } val subId = "geode-mirror-$index" + val downSub = DownSub(subId, up, scopedBase, listener, initialSince, watermark, deletionScope) client.subscribe( subId = subId, - filters = mapOf(up.url to listOf(scopedBase.copy(since = initialSince))), + filters = mapOf(up.url to downSub.filtersFrom(initialSince)), listener = listener, ) - return DownSub(subId, up, scopedBase, listener, initialSince, watermark) + return downSub } /** @@ -638,6 +673,7 @@ class MirrorWorker( up: MirrorUpstream, scopedFilter: Filter, exchanged: RecentIds?, + deletionScope: Filter?, ) { val session = server.connect { json -> @@ -645,6 +681,9 @@ class MirrorWorker( val event = runCatching { (OptimizedJsonMapper.fromJsonToMessage(json) as? EventMessage)?.event } .getOrNull() ?: return@connect + // A kind-62 vanish only goes to a relay it targets; kind-5 always + // goes. Content events are already scoped by the session's REQ. + if (!shouldPropagateDeletionUp(event, up.url)) return@connect // BOTH: don't push back what we just pulled down. if (exchanged?.contains(event.id) == true) return@connect exchanged?.add(event.id) @@ -652,8 +691,9 @@ class MirrorWorker( sentUp.incrementAndGet() } upSessions += AutoCloseable { session.close() } + val reqFilters = listOfNotNull(scopedFilter, deletionScope) scope.launch { - session.receive(OptimizedJsonMapper.toJson(ReqCmd("geode-mirror-up", listOf(scopedFilter)))) + session.receive(OptimizedJsonMapper.toJson(ReqCmd("geode-mirror-up", reqFilters))) } } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt new file mode 100644 index 0000000000..244f4916c9 --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -0,0 +1,194 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.geode.testing.publish +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyPropagateDeletions +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.shouldPropagateDeletionUp +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * A NIP-77 sync reconciles by id over the content filter, so a scoped sync + * (`--kind 1`) never carries the kind-5/62 that would delete one of those notes + * — the deletion is stuck on whichever side issued it. The deletion side-channel + * ([negentropyPropagateDeletions]) closes that gap by reconciling kinds 5 & 62 + * on their own, both directions, independent of the content filter. + * + * Scenario under test (the user's "Relay A has a deletion, Relay B doesn't"): + * the note lives on both sides; a kind-5 deleting it lives on only one. After the + * side-channel runs, the deletion has reached the other side and the note is gone + * there too. + */ +class DeletionSyncTest : RelayClientTest() { + private val signer = NostrSignerSync(KeyPair()) + + private fun note(text: String): Event = signer.sign(TextNoteEvent.build(text)) + + private fun deletionOf(target: Event): Event = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + + // ---- filter helpers (pure) -------------------------------------------- + + @Test + fun unscopedFilterNeedsNoSideChannel() { + assertFalse(Filter().excludesDeletionKinds(), "no kinds constraint already matches deletions") + assertFalse(Filter(kinds = listOf(1, 5)).excludesDeletionKinds(), "explicit kind 5 is covered") + assertFalse(Filter(kinds = listOf(62)).excludesDeletionKinds(), "explicit kind 62 is covered") + assertTrue(Filter(kinds = listOf(1)).excludesDeletionKinds(), "kind-1-only drops deletions") + } + + @Test + fun sideChannelFilterCarriesAuthorsNotWindow() { + val authored = Filter(kinds = listOf(1), authors = listOf("aa", "bb"), since = 100, until = 200) + val side = authored.deletionSideChannelFilter() + + assertEquals(listOf(DeletionEvent.KIND, RequestToVanishEvent.KIND), side.kinds) + assertEquals(listOf("aa", "bb"), side.authors, "author scope is inherited") + assertNull(side.since, "no time window: a deletion's created_at is not its target's") + assertNull(side.until) + } + + @Test + fun vanishGateHonorsDeclaredTargets() { + val here = defaultRelayUrl + val elsewhere = RelayUrlNormalizer.normalize("wss://elsewhere.example/") + + val delete = deletionOf(note("x")) + val vanishHere = signer.sign(RequestToVanishEvent.build(here)) + val vanishElsewhere = signer.sign(RequestToVanishEvent.build(elsewhere)) + val vanishEverywhere = signer.sign(RequestToVanishEvent.buildVanishFromEverywhere()) + + assertTrue(shouldPropagateDeletionUp(delete, elsewhere), "kind-5 is always safe to propagate") + assertTrue(shouldPropagateDeletionUp(vanishHere, here), "vanish targeting this relay goes") + assertFalse(shouldPropagateDeletionUp(vanishElsewhere, here), "vanish for another relay does not") + assertTrue(shouldPropagateDeletionUp(vanishEverywhere, here), "ALL_RELAYS vanish goes anywhere") + } + + @Test + fun noOpWhenFilterAlreadyCoversDeletions() = + runBlocking { + val result = + withTimeout(20_000) { + client.negentropyPropagateDeletions( + relay = defaultRelayUrl, + contentFilter = Filter(kinds = listOf(1, 5)), + localDeletions = listOf(deletionOf(note("x"))), + download = { error("must not download") }, + upload = { error("must not upload") }, + ) + } + assertNull(result, "a filter that already covers 5/62 skips the side-channel") + } + + // ---- up: local has the deletion, relay does not ----------------------- + + @Test + fun pushesLocalDeletionUpSoRelayRemovesTarget() = + runBlocking { + val note = note("delete me") + val deletion = deletionOf(note) + + // Relay B: has the note, no deletion. + defaultRelay.preload(listOf(note)) + assertEquals(1, defaultRelay.store.query(Filter(ids = listOf(note.id))).size) + + // Local side (Relay A) already applied the deletion, so it holds only + // the kind-5. A content sync over kind 1 would never carry it. + val uploaded = mutableListOf() + withTimeout(20_000) { + client.negentropyPropagateDeletions( + relay = defaultRelayUrl, + contentFilter = Filter(kinds = listOf(1)), + localDeletions = listOf(deletion), + download = { error("relay has no deletions to pull") }, + upload = { event -> + uploaded += event + defaultRelay.publish(event) + }, + ) + } + + assertEquals(listOf(deletion.id), uploaded.map { it.id }, "the deletion was pushed up") + assertTrue( + defaultRelay.store.query(Filter(ids = listOf(note.id))).isEmpty(), + "relay applied the pushed deletion and removed the note", + ) + } + + // ---- down: relay has the deletion, local does not --------------------- + + @Test + fun pullsRelayDeletionDownSoLocalRemovesTarget() = + runBlocking { + val note = note("delete me too") + val deletion = deletionOf(note) + + // Remote relay already applied the deletion → holds only the kind-5. + defaultRelay.preload(listOf(note, deletion)) + assertTrue( + defaultRelay.store.query(Filter(ids = listOf(note.id))).isEmpty(), + "precondition: relay removed the note when it ingested the deletion", + ) + + // Local side: a second store still holding the note, no deletion. + val localUrl = RelayUrlNormalizer.normalize("ws://local-a/") + val local = hub.getOrCreate(localUrl) + local.preload(listOf(note)) + assertEquals(1, local.store.query(Filter(ids = listOf(note.id))).size) + + withTimeout(20_000) { + client.negentropyPropagateDeletions( + relay = defaultRelayUrl, + contentFilter = Filter(kinds = listOf(1)), + localDeletions = emptyList(), + download = { ids: List -> + // Stand-in for REQ-by-id + verify + store: pull from the + // remote in-process store and ingest into the local one. + defaultRelay.store.query(Filter(ids = ids)).forEach { local.store.insert(it) } + }, + upload = { error("local has no deletions to push") }, + ) + } + + assertTrue( + local.store.query(Filter(ids = listOf(note.id))).isEmpty(), + "local store applied the pulled deletion and removed the note", + ) + } +} diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt new file mode 100644 index 0000000000..8b0be63d5e --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt @@ -0,0 +1,114 @@ +/* + * 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.geode.mirror + +import com.vitorpamplona.geode.KtorRelay +import com.vitorpamplona.geode.RelayEngine +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A kind-scoped mirror (`filter = {kinds:[1]}`) must still propagate the kind-5/62 + * that delete those notes — otherwise the deletion is stuck upstream and the note + * lives forever on the mirror. [MirrorWorker]'s deletion side-channel reconciles + * kinds 5/62 on their own, in the mirror's configured direction, independent of + * the operator filter. + */ +class MirrorDeletionSyncTest { + private val upstreamStore = EventStore(null) + private val downstreamStore = EventStore(null) + + private val upstream = RelayEngine(url = "ws://127.0.0.1:7896/".normalizeRelayUrl(), store = upstreamStore) + private val downstream = + RelayEngine(url = "ws://127.0.0.1:7897/".normalizeRelayUrl(), store = downstreamStore, parallelVerify = true) + + private var server: KtorRelay? = null + private var worker: MirrorWorker? = null + + @AfterTest + fun tearDown() { + worker?.close() + server?.stop(gracePeriodMillis = 0, timeoutMillis = 1_000) + upstream.close() + downstream.close() + } + + @Test + fun scopedDownMirrorPropagatesDeletion() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val note = signer.sign(TextNoteEvent.build("delete me")) + val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + + // Upstream already applied the deletion → it holds only the kind-5. + upstreamStore.insert(note) + upstreamStore.insert(deletion) + assertEquals(0, upstreamStore.count(Filter(ids = listOf(note.id))), "upstream removed the note") + + // Downstream (the mirror) still holds the note, no deletion. + downstreamStore.insert(note) + assertEquals(1, downstreamStore.count(Filter(ids = listOf(note.id))), "mirror starts with the note") + + server = KtorRelay(upstream, host = "127.0.0.1", port = 7896).start() + + worker = + MirrorWorker( + upstreams = + listOf( + MirrorUpstream( + url = "ws://127.0.0.1:7896/".normalizeRelayUrl(), + trusted = true, + backfillSeconds = 86_400, + // Scoped to kind 1 — would drop the kind-5 without the side-channel. + filter = Filter(kinds = listOf(1)), + ), + ), + server = downstream.server, + store = downstreamStore, + negentropyBackfill = true, + ).also { it.start() } + + val gone = + withTimeoutOrNull(30_000) { + while (downstreamStore.count(Filter(ids = listOf(note.id))) > 0) delay(200) + true + } + + assertTrue(gone == true, "scoped down mirror did not propagate the deletion") + assertEquals( + 1, + downstreamStore.count(Filter(kinds = listOf(DeletionEvent.KIND))), + "mirror ingested the deletion event itself", + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt new file mode 100644 index 0000000000..7e34191495 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt @@ -0,0 +1,134 @@ +/* + * 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.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent + +/** + * Event kinds that carry *deletion intent* — NIP-09 deletion requests (kind 5) + * and NIP-62 request-to-vanish (kind 62). They are propagation instructions, not + * content, so a sync should carry them **regardless of its content filter**: a + * `--kind 1` sync that dropped the kind-5 deleting one of those notes would leave + * the note un-deleted on the other side forever. + */ +val DELETION_PROPAGATION_KINDS = listOf(DeletionEvent.KIND, RequestToVanishEvent.KIND) + +/** + * Whether this content [Filter] would *exclude* the deletion kinds — i.e. it pins + * `kinds` and none of them is 5/62. A filter with no `kinds` constraint already + * matches deletions, so it needs no side-channel. Anything else (a scoped `kinds` + * list without 5/62) would silently drop deletions and wants + * [deletionSideChannelFilter]. + */ +fun Filter.excludesDeletionKinds(): Boolean { + val k = kinds ?: return false + return DELETION_PROPAGATION_KINDS.none { it in k } +} + +/** + * The companion "deletion side-channel" filter for a content [Filter]: kinds 5/62, + * scoped to the same `authors`. A kind-5/62 only affects its own author's events, so + * when the content sync is author-scoped the deletions worth reconciling are exactly + * those authors'. Carries **no** `since`/`until`: a deletion's `created_at` is when it + * was issued, not when its target was created, so inheriting the content window would + * drop a recent deletion of an old event (or an old deletion synced late). + */ +fun Filter.deletionSideChannelFilter(): Filter = Filter(kinds = DELETION_PROPAGATION_KINDS, authors = authors) + +/** + * Whether a local deletion-family [event] may be pushed UP to [relay]. + * + * - **kind 5** — always. A deletion request is owner-scoped (the relay only removes + * the deleting author's own events), so propagating it can never delete a third + * party's data; the worst case is a no-op the relay ignores. + * - **kind 62** — only when the request actually targets [relay] (its `relay` tags + * name that URL, or `ALL_RELAYS`). A vanish triggers a pubkey-wide mass delete on + * every relay that ingests it, so we must not fan one out to a relay the author + * never named — we honor the author's declared targets, no broader. + */ +fun shouldPropagateDeletionUp( + event: Event, + relay: NormalizedRelayUrl, +): Boolean = + when (event) { + is RequestToVanishEvent -> event.shouldVanishFrom(relay) + else -> true + } + +/** + * Propagates deletion-family events (kinds 5 & 62) between the local set and [relay], + * **independent of a content sync's [contentFilter]** and **always bidirectional**: + * + * - **down** — deletions the relay has and we lack are handed to [download]; feeding + * them into the local store lets NIP-09/62 remove the targets locally too (and its + * reject-trigger keeps them from being re-added by a later content sync). + * - **up** — deletions we have and the relay lacks are handed to [upload]; publishing + * them makes the relay apply the deletion. Kind-62 vanishes are gated by + * [shouldPropagateDeletionUp] so one is only sent to a relay it targets. + * + * A no-op returning `null` when [contentFilter] already covers kinds 5/62 (an + * unscoped or already-deletion-including sync carries them itself). Otherwise runs one + * extra [negentropyReconcile] over [deletionSideChannelFilter]; the set is tiny on any + * real store, so the cost is a single short reconcile, not a second full sync. + * + * The caller owns I/O: [download] fetches + ingests the given ids however it fetches + * content (REQ-by-id, verify, store), and [upload] publishes one local event. Both + * suspend the reconcile round that produced them, so the relay is back-pressured. + * + * @param localDeletions the local kind-5/62 events — both the reconcile set and the + * source the `have` ids resolve against for [upload]. + */ +suspend fun INostrClient.negentropyPropagateDeletions( + relay: NormalizedRelayUrl, + contentFilter: Filter, + localDeletions: List, + batchSize: Int = 500, + idleTimeoutMs: Long = 120_000L, + download: suspend (List) -> Unit, + upload: suspend (Event) -> Unit, +): NegentropyReconcileResult? { + if (!contentFilter.excludesDeletionKinds()) return null + + val byId = localDeletions.associateBy { it.id } + val localEntries = localDeletions.map { IdAndTime(it.createdAt, it.id) } + + return negentropyReconcile( + relay = relay, + filter = contentFilter.deletionSideChannelFilter(), + localEntries = localEntries, + batchSize = batchSize, + idleTimeoutMs = idleTimeoutMs, + onNeedIds = { batch -> download(batch) }, + onHaveIds = { batch -> + for (id in batch) { + val event = byId[id] ?: continue + if (shouldPropagateDeletionUp(event, relay)) upload(event) + } + }, + ) +} From f8b2f179794d548593f9cd6e1e208fc2293aa855 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 01:25:18 +0000 Subject: [PATCH 02/12] feat: deletions-first ordering + reject-reaction backstop in sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reorder amy sync so both sides fully reflect each other, including deletions: 1. Deletion side-channel now runs FIRST, before content, in both directions. The content snapshot is taken AFTER it, closing a resurrection bug: a deletion pulled down mid-sync removes a local event, but the old top-of-run snapshot still listed it and would re-offer it up — resurrecting it on a relay that also lacked the deletion. 2. Content reconcile, over the post-deletion snapshot. 3. Reject-reaction backstop: when the relay blocks a content push (usually it holds a deletion we lack), pull that author's kind-5/62 and ingest locally so we stop re-offering the dead event. Verify-by-fetch — only a real deletion the store accepts has any effect; fires only on an actual reject. The up-push of deletions is already verified per-event: ctx.publish awaits the relay's OK, and ingesting the kind-5 runs the delete synchronously, so OK=true confirms the remote applied it. Mirror catch-up gets the same deletions-first ordering (down and up), so a deletion lands, or the reject-trigger is armed, before its target — no add-then-delete churn. Tests: MirrorDeletionSyncTest gains scopedUpMirrorPushesDeletion (authoritative push — local holds the deletion, remote drops the note). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../amethyst/cli/commands/SyncCommand.kt | 119 ++++++++++++------ .../geode/mirror/MirrorWorker.kt | 15 ++- .../geode/mirror/MirrorDeletionSyncTest.kt | 54 ++++++++ 3 files changed, 146 insertions(+), 42 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index c7ad0ce781..3410a33b4d 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DELETION_PROPAGATION_KINDS import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds @@ -38,6 +39,7 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** @@ -57,15 +59,24 @@ import java.util.concurrent.atomic.AtomicInteger * Pass both for a full bidirectional sync. The filter flags are the same as * `fetch`/`subscribe`; an empty filter reconciles the whole store. * - * Deletions ride a side-channel. NIP-77 reconciles by id over the content - * filter, so a scoped sync (`--kind 1`) would never carry the kind-5/62 that - * deletes one of those notes — the deletion would be stuck on whichever side - * issued it. So whenever the filter pins `kinds` and excludes 5/62, a second - * reconcile over `{kinds:[5,62], authors:}` runs **both directions - * regardless of --up/--down** ([negentropyPropagateDeletions]): local deletions - * are pushed up so the relay applies them, and the relay's deletions are pulled - * down so the local store applies them. A kind-62 vanish is only pushed to a - * relay it actually targets. Pass `--no-sync-deletions` to opt out. + * Deletions ride a side-channel, and run in three phases so both sides fully + * reflect each other. NIP-77 reconciles by id over the content filter, so a + * scoped sync (`--kind 1`) would never carry the kind-5/62 that deletes one of + * those notes — the deletion would be stuck on whichever side issued it. + * + * 1. Whenever the filter pins `kinds` and excludes 5/62, reconcile + * `{kinds:[5,62], authors:}` **both directions regardless of + * --up/--down** ([negentropyPropagateDeletions]) — FIRST, before content, + * so every deletion is applied on both sides before the content diff is + * taken. Local deletions are pushed up (a kind-62 vanish only to a relay it + * targets); the relay's deletions are pulled down and applied locally. + * 2. Content reconcile, over a local snapshot taken AFTER phase 1 so it never + * re-offers (and cannot resurrect on the relay) an event just deleted. + * 3. Backstop: if the relay rejected a content push — usually because it holds + * a deletion we lack — pull that author's kind-5/62 and apply it locally so + * we stop re-offering the dead event. + * + * Pass `--no-sync-deletions` to opt out of all three. * * Both directions are pipelined with the reconcile: need-id batches feed * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single @@ -111,12 +122,53 @@ object SyncCommand { Context.open(dataDir).use { ctx -> ctx.prepare() + + val deletionsDown = AtomicInteger(0) + val deletionsUp = AtomicInteger(0) + + // ── Phase 1: deletions first, both directions ──────────────────── + // Propagate kind 5/62 before content so both stores have applied every + // deletion by the time content is diffed. Ordering is load-bearing: a + // deletion pulled down here removes a local event, so the content + // snapshot MUST be taken AFTER this phase — a snapshot taken before + // would still list the just-deleted event and re-offer it up, which the + // relay would either reject or (if it also lacks the deletion) resurrect. + // No-op (returns null) when the content filter already covers 5/62. + val deletionResult = + if (syncDeletions && filter.excludesDeletionKinds()) { + try { + val localDeletions = ctx.store.query(filter.deletionSideChannelFilter()) + ctx.client.negentropyPropagateDeletions( + relay = relay, + contentFilter = filter, + localDeletions = localDeletions, + idleTimeoutMs = timeoutMs, + download = { batch -> + deletionsDown.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) + }, + upload = { event -> + if (ctx.publish(event, setOf(relay)).values.any { it }) deletionsUp.incrementAndGet() + }, + ) + } catch (e: NegentropySyncException) { + return Output.error("sync_error", e.message ?: "deletion sync failed") + } + } else { + null + } + + // ── Phase 2: content ───────────────────────────────────────────── + // Snapshot the local set AFTER the deletion phase so it reflects any + // deletion just applied — never re-offering an event we just deleted. val localEvents = ctx.store.query(filter) val localById = localEvents.associateBy { it.id } val localEntries = localEvents.map { IdAndTime(it.createdAt, it.id) } val downloaded = AtomicInteger(0) val uploaded = AtomicInteger(0) + // Authors whose content push the relay rejected — a rejection usually + // means the relay holds a deletion we lack (Phase 3 reconciles them). + val blockedAuthors = ConcurrentHashMap.newKeySet() val result = try { @@ -144,7 +196,14 @@ object SyncCommand { for (id in batch) { val ev = localById[id] ?: continue val ack = ctx.publish(ev, setOf(relay)) - if (ack.values.any { it }) uploaded.incrementAndGet() + if (ack.values.any { it }) { + uploaded.incrementAndGet() + } else if (syncDeletions) { + // Relay refused it — most often because it holds a + // deletion for this id that we lack. Remember the + // author so Phase 3 can pull that deletion down. + blockedAuthors.add(ev.pubKey) + } } } } @@ -174,33 +233,19 @@ object SyncCommand { return Output.error("sync_error", e.message ?: "negentropy sync failed") } - // Deletion side-channel: propagate kind 5/62 both ways, independent of - // the content filter, so a scoped sync still carries the deletions that - // apply to it. No-op (returns null) when the filter already covers 5/62. - val deletionsDown = AtomicInteger(0) - val deletionsUp = AtomicInteger(0) - val deletionResult = - if (syncDeletions && filter.excludesDeletionKinds()) { - try { - val localDeletions = ctx.store.query(filter.deletionSideChannelFilter()) - ctx.client.negentropyPropagateDeletions( - relay = relay, - contentFilter = filter, - localDeletions = localDeletions, - idleTimeoutMs = timeoutMs, - download = { batch -> - deletionsDown.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) - }, - upload = { event -> - if (ctx.publish(event, setOf(relay)).values.any { it }) deletionsUp.incrementAndGet() - }, - ) - } catch (e: NegentropySyncException) { - return Output.error("sync_error", e.message ?: "deletion sync failed") - } - } else { - null - } + // ── Phase 3: reject-reaction backstop ──────────────────────────── + // A content push the relay blocked usually means the relay deleted that + // id and holds the deletion we lack. Pull that author's deletions and + // ingest them locally so we stop re-offering the dead event. Cheap — + // only fires on an actual rejection, and verify-by-fetch: only a real + // kind-5/62 that the store accepts has any effect. Rarely triggers once + // Phase 1 ran, but it is the net when deletions are otherwise skipped. + if (blockedAuthors.isNotEmpty()) { + ctx.drain( + mapOf(relay to listOf(Filter(kinds = DELETION_PROPAGATION_KINDS, authors = blockedAuthors.toList()))), + timeoutMs, + ) + } Output.emit( mapOf( diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index 8abf263d95..2155353ed7 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -484,11 +484,13 @@ class MirrorWorker( } try { - pull(catchUpFilter) - // Deletions carry no time window: a deletion's created_at is when it - // was issued, not when its target was, so the whole deletion set for - // the scope is reconciled rather than the [initialSince, until] window. + // Deletions first, so a deletion already lands (or the reject-trigger + // is armed) before the content pull can add its target — no + // add-then-delete churn. They carry no time window: a deletion's + // created_at is when it was issued, not when its target was, so the + // whole deletion set for the scope is reconciled, not the window. if (deletionScope != null) pull(deletionScope) + pull(catchUpFilter) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -577,10 +579,13 @@ class MirrorWorker( } try { - pushUp(catchUpFilter, "content") { event -> up.filter == null || up.filter.match(event) } + // Deletions first: push a deletion up before its target, so the + // upstream's reject-trigger blocks the target instead of ingesting + // then deleting it. if (deletionScope != null) { pushUp(deletionScope, "deletions") { event -> shouldPropagateDeletionUp(event, up.url) } } + pushUp(catchUpFilter, "content") { event -> up.filter == null || up.filter.match(event) } } catch (e: CancellationException) { throw e } catch (e: Throwable) { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt index 8b0be63d5e..7d37ee8389 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt @@ -111,4 +111,58 @@ class MirrorDeletionSyncTest { "mirror ingested the deletion event itself", ) } + + /** + * The authoritative-push case: the local relay holds the deletion (its note + * already removed), the remote still holds the note, and a scoped `dir = up` + * mirror must push the kind-5 up so the remote reflects the local state. + */ + @Test + fun scopedUpMirrorPushesDeletion() = + runBlocking { + val signer = NostrSignerSync(KeyPair()) + val note = signer.sign(TextNoteEvent.build("delete me")) + val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) + + // Local (downstream) is authoritative: it already applied the deletion. + downstreamStore.insert(note) + downstreamStore.insert(deletion) + assertEquals(0, downstreamStore.count(Filter(ids = listOf(note.id))), "local removed its note") + + // Remote (upstream, the sink geode dials) still holds the note. + upstreamStore.insert(note) + assertEquals(1, upstreamStore.count(Filter(ids = listOf(note.id))), "remote still has the note") + + server = KtorRelay(upstream, host = "127.0.0.1", port = 7896).start() + + worker = + MirrorWorker( + upstreams = + listOf( + MirrorUpstream( + url = "ws://127.0.0.1:7896/".normalizeRelayUrl(), + trusted = false, + backfillSeconds = 86_400, + direction = MirrorDirection.UP, + filter = Filter(kinds = listOf(1)), + ), + ), + server = downstream.server, + store = downstreamStore, + negentropyBackfill = true, + ).also { it.start() } + + val gone = + withTimeoutOrNull(30_000) { + while (upstreamStore.count(Filter(ids = listOf(note.id))) > 0) delay(200) + true + } + + assertTrue(gone == true, "scoped up mirror did not push the deletion to the remote") + assertEquals( + 1, + upstreamStore.count(Filter(kinds = listOf(DeletionEvent.KIND))), + "remote received the deletion event", + ) + } } From 17687e43fc6ce411f960d8666a038dd2b4fbc567 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 02:14:08 +0000 Subject: [PATCH 03/12] fix: bound deletion sync scope; stop mass over-deletion (audit fixes) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An audit (adversarial-verified) found the deletion side-channel over-deletes and over-propagates. Root cause: deletionSideChannelFilter fell open to authors=null for any non-author-scoped content sync, so `amy sync --kind 1` reconciled the RELAY'S ENTIRE kind-5/62 history and applied it to the personal FsEventStore — every kind-5 deleting its targets + installing an id-tombstone for every target id, every ALL_RELAYS kind-62 wiping all of a pubkey's events (all kinds), and pushing our whole local deletion history up. Data loss plus a full-history reconcile on every scoped sync. Fixes: - Bound the side-channel to the authors we actually hold content for (filter authors ∪ local matched-set authors), never the relay's population. Skip when that scope is empty; Phase 3's reject-reaction covers the author-less case. - Kind-5 (precise, owner-scoped) propagates by default; kind-62 vanish is opt-in via --sync-vanish (its blast radius always exceeds a content sync's scope). - excludesDeletionKinds() now checks each deletion kind independently (`--kind 1,5` no longer silently drops kind-62); the side-channel reconciles only the missing kinds. - amy Phase 1 is best-effort: a deletion-reconcile failure records deletions_error and falls through to content, never aborting the primary sync (matches geode). - Mirror up-catch-up converges on whether a PUBLISHABLE event was pushed, not raw haveCount — a vanish targeting another relay no longer burns all 8 rounds every startup. Mirror keeps its (correct) global scope for relay-to-relay replication. Helper API: negentropyPropagateDeletions gains scopeAuthors + deletionKinds; deletionSideChannelFilter takes authors + deletionKinds and returns only the missing kinds. Tests updated for the new semantics. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../amethyst/cli/commands/SyncCommand.kt | 90 +++++++++++++------ .../geode/mirror/MirrorWorker.kt | 10 ++- .../vitorpamplona/geode/DeletionSyncTest.kt | 59 ++++++++---- .../accessories/NostrClientDeletionSyncExt.kt | 72 ++++++++++----- 4 files changed, 162 insertions(+), 69 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 3410a33b4d..09ef31b197 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -35,6 +35,7 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyRec import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.store.IdAndTime +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll @@ -59,24 +60,27 @@ import java.util.concurrent.atomic.AtomicInteger * Pass both for a full bidirectional sync. The filter flags are the same as * `fetch`/`subscribe`; an empty filter reconciles the whole store. * - * Deletions ride a side-channel, and run in three phases so both sides fully - * reflect each other. NIP-77 reconciles by id over the content filter, so a - * scoped sync (`--kind 1`) would never carry the kind-5/62 that deletes one of - * those notes — the deletion would be stuck on whichever side issued it. + * Deletions ride a side-channel, in three phases, so both sides reflect each + * other. NIP-77 reconciles by id over the content filter, so a scoped sync + * (`--kind 1`) would never carry the kind-5 that deletes one of those notes — + * the deletion would be stuck on whichever side issued it. * - * 1. Whenever the filter pins `kinds` and excludes 5/62, reconcile - * `{kinds:[5,62], authors:}` **both directions regardless of - * --up/--down** ([negentropyPropagateDeletions]) — FIRST, before content, - * so every deletion is applied on both sides before the content diff is - * taken. Local deletions are pushed up (a kind-62 vanish only to a relay it - * targets); the relay's deletions are pulled down and applied locally. + * 1. Reconcile the missing deletion kinds — FIRST, before content, so every + * deletion is applied on both sides before the content diff is taken. The + * reconcile is **bounded to the authors we hold content for** (the filter's + * authors ∪ our local matched set's authors), never the relay's whole + * population — an author-less pull would otherwise import the relay's entire + * deletion history and mass-delete this local store. Skipped when we hold + * nothing in scope. Best-effort: a failure here never aborts the sync. * 2. Content reconcile, over a local snapshot taken AFTER phase 1 so it never * re-offers (and cannot resurrect on the relay) an event just deleted. * 3. Backstop: if the relay rejected a content push — usually because it holds - * a deletion we lack — pull that author's kind-5/62 and apply it locally so - * we stop re-offering the dead event. + * a deletion we lack — pull that author's deletions and apply them locally. + * This is the down-convergence path for author-less syncs (phase 1 skipped). * - * Pass `--no-sync-deletions` to opt out of all three. + * Kind-5 (precise, owner-scoped) propagates by default. Kind-62 Request-to-Vanish + * mass-deletes ALL of a pubkey's events, so it is opt-in via `--sync-vanish`. + * Pass `--no-sync-deletions` to disable deletion propagation entirely. * * Both directions are pipelined with the reconcile: need-id batches feed * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single @@ -118,6 +122,10 @@ object SyncCommand { val up = args.bool("up") val down = args.bool("down") || !up val syncDeletions = !args.bool("no-sync-deletions") + // Which deletion kinds the side-channel carries. Kind-5 (precise, owner-scoped) + // by default; kind-62 Request-to-Vanish is opt-in because it mass-deletes ALL of + // a pubkey's events, a blast radius that always exceeds a content sync's scope. + val deletionKinds = if (args.bool("sync-vanish")) DELETION_PROPAGATION_KINDS else listOf(DeletionEvent.KIND) val filter = RawEventSupport.buildFilter(args) Context.open(dataDir).use { ctx -> @@ -125,23 +133,46 @@ object SyncCommand { val deletionsDown = AtomicInteger(0) val deletionsUp = AtomicInteger(0) + var deletionsError: String? = null // ── Phase 1: deletions first, both directions ──────────────────── - // Propagate kind 5/62 before content so both stores have applied every + // Propagate deletions before content so both stores have applied every // deletion by the time content is diffed. Ordering is load-bearing: a - // deletion pulled down here removes a local event, so the content - // snapshot MUST be taken AFTER this phase — a snapshot taken before - // would still list the just-deleted event and re-offer it up, which the - // relay would either reject or (if it also lacks the deletion) resurrect. - // No-op (returns null) when the content filter already covers 5/62. + // deletion pulled down here removes a local event, so the content snapshot + // MUST be taken AFTER this phase — a snapshot taken before would still list + // the just-deleted event and re-offer it up, resurrecting it on a relay + // that also lacks the deletion. + // + // SCOPE. The reconcile is bounded to the authors we actually hold content + // for (the content filter's authors ∪ the authors in our local matched + // set) — NEVER the relay's whole population. An author-less filter against a + // public relay would otherwise pull the relay's entire deletion history and + // apply it to this personal store, mass-deleting cached events far outside + // the sync's scope. When we hold nothing in scope there is nothing to + // reconcile, so the side-channel is skipped and Phase 3 covers the rest. + // Only compute the scope (a store read) when a side-channel could actually + // run — a whole-store sync already carries deletions and must not pay a + // full-store scan here. + val runSideChannel = syncDeletions && filter.excludesDeletionKinds(deletionKinds) + val scopeAuthors = + if (runSideChannel) { + ((filter.authors ?: emptyList()) + ctx.store.query(filter).map { it.pubKey }).distinct() + } else { + emptyList() + } val deletionResult = - if (syncDeletions && filter.excludesDeletionKinds()) { + if (runSideChannel && scopeAuthors.isNotEmpty()) { + // Best-effort: a deletion-reconcile failure must NEVER abort the + // primary content sync (matches geode's mirror policy). Record it + // and fall through to content + the Phase-3 backstop. try { - val localDeletions = ctx.store.query(filter.deletionSideChannelFilter()) + val localDeletions = ctx.store.query(filter.deletionSideChannelFilter(scopeAuthors, deletionKinds)) ctx.client.negentropyPropagateDeletions( relay = relay, contentFilter = filter, localDeletions = localDeletions, + scopeAuthors = scopeAuthors, + deletionKinds = deletionKinds, idleTimeoutMs = timeoutMs, download = { batch -> deletionsDown.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) @@ -151,7 +182,8 @@ object SyncCommand { }, ) } catch (e: NegentropySyncException) { - return Output.error("sync_error", e.message ?: "deletion sync failed") + deletionsError = e.message ?: "deletion sync failed" + null } } else { null @@ -236,13 +268,14 @@ object SyncCommand { // ── Phase 3: reject-reaction backstop ──────────────────────────── // A content push the relay blocked usually means the relay deleted that // id and holds the deletion we lack. Pull that author's deletions and - // ingest them locally so we stop re-offering the dead event. Cheap — - // only fires on an actual rejection, and verify-by-fetch: only a real - // kind-5/62 that the store accepts has any effect. Rarely triggers once - // Phase 1 ran, but it is the net when deletions are otherwise skipped. - if (blockedAuthors.isNotEmpty()) { + // ingest them locally so we stop re-offering the dead event. Cheap and + // precisely bounded — only fires on an actual rejection, only for the + // rejected authors, and verify-by-fetch: only a real deletion the store + // accepts has any effect. This is the primary down-convergence path for + // author-less syncs (where Phase 1 is intentionally skipped). + if (syncDeletions && blockedAuthors.isNotEmpty()) { ctx.drain( - mapOf(relay to listOf(Filter(kinds = DELETION_PROPAGATION_KINDS, authors = blockedAuthors.toList()))), + mapOf(relay to listOf(Filter(kinds = deletionKinds, authors = blockedAuthors.toList()))), timeoutMs, ) } @@ -260,6 +293,7 @@ object SyncCommand { "deletions_have" to (deletionResult?.haveCount ?: 0), "deletions_downloaded" to deletionsDown.get(), "deletions_uploaded" to deletionsUp.get(), + "deletions_error" to (deletionsError ?: ""), ), ) return 0 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index 2155353ed7..ae621a5415 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -566,8 +566,14 @@ class MirrorWorker( }, onNeedIds = { }, ) - if (haveCount == 0) { - Log.i("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: converged after $round round(s)" } + // Converge when nothing PUBLISHABLE remains to push — not on raw + // haveCount. A diff that is all un-publishable (e.g. a kind-62 vanish + // for a relay this upstream isn't, which shouldPropagateDeletionUp + // rightly refuses) would otherwise report a non-zero haveCount every + // round and burn all MAX_UP_SYNC_ROUNDS on every startup. + if (pushed == 0) { + val how = if (haveCount == 0) "converged" else "converged ($haveCount un-publishable left)" + Log.i("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: $how after $round round(s)" } return } sentUp.addAndGet(pushed.toLong()) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt index 244f4916c9..e510aaf801 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -66,20 +66,27 @@ class DeletionSyncTest : RelayClientTest() { // ---- filter helpers (pure) -------------------------------------------- @Test - fun unscopedFilterNeedsNoSideChannel() { - assertFalse(Filter().excludesDeletionKinds(), "no kinds constraint already matches deletions") - assertFalse(Filter(kinds = listOf(1, 5)).excludesDeletionKinds(), "explicit kind 5 is covered") - assertFalse(Filter(kinds = listOf(62)).excludesDeletionKinds(), "explicit kind 62 is covered") - assertTrue(Filter(kinds = listOf(1)).excludesDeletionKinds(), "kind-1-only drops deletions") + fun excludesDeletionKindsChecksEachKindIndependently() { + // No kinds constraint already matches every deletion kind. + assertFalse(Filter().excludesDeletionKinds()) + // Listing ONE deletion kind does NOT cover the other — the reconcile + // carries only the kinds actually listed. + assertTrue(Filter(kinds = listOf(1, 5)).excludesDeletionKinds(), "kind 5 listed, kind 62 still missing") + assertTrue(Filter(kinds = listOf(62)).excludesDeletionKinds(), "kind 62 listed, kind 5 still missing") + assertTrue(Filter(kinds = listOf(1)).excludesDeletionKinds(), "kind-1-only misses both") + // Both listed → nothing missing. + assertFalse(Filter(kinds = listOf(5, 62)).excludesDeletionKinds(), "both deletion kinds covered") + // With a restricted deletionKinds set, only kind 5 matters. + assertFalse(Filter(kinds = listOf(1, 5)).excludesDeletionKinds(listOf(DeletionEvent.KIND))) } @Test - fun sideChannelFilterCarriesAuthorsNotWindow() { - val authored = Filter(kinds = listOf(1), authors = listOf("aa", "bb"), since = 100, until = 200) - val side = authored.deletionSideChannelFilter() + fun sideChannelFilterCarriesMissingKindsScopedAuthorsNoWindow() { + val authored = Filter(kinds = listOf(1, 5), authors = listOf("aa", "bb"), since = 100, until = 200) + val side = authored.deletionSideChannelFilter(authors = listOf("aa", "bb")) - assertEquals(listOf(DeletionEvent.KIND, RequestToVanishEvent.KIND), side.kinds) - assertEquals(listOf("aa", "bb"), side.authors, "author scope is inherited") + assertEquals(listOf(RequestToVanishEvent.KIND), side.kinds, "kind 5 already covered → only 62 missing") + assertEquals(listOf("aa", "bb"), side.authors, "explicit author scope") assertNull(side.since, "no time window: a deletion's created_at is not its target's") assertNull(side.until) } @@ -101,19 +108,37 @@ class DeletionSyncTest : RelayClientTest() { } @Test - fun noOpWhenFilterAlreadyCoversDeletions() = + fun noOpWhenAllKindsCoveredOrScopeEmpty() = runBlocking { - val result = + val d = deletionOf(note("x")) + // All deletion kinds already covered by content → skip. + assertNull( withTimeout(20_000) { client.negentropyPropagateDeletions( relay = defaultRelayUrl, - contentFilter = Filter(kinds = listOf(1, 5)), - localDeletions = listOf(deletionOf(note("x"))), + contentFilter = Filter(kinds = listOf(5, 62)), + localDeletions = listOf(d), + scopeAuthors = listOf(signer.pubKey), download = { error("must not download") }, upload = { error("must not upload") }, ) - } - assertNull(result, "a filter that already covers 5/62 skips the side-channel") + }, + "a filter that already covers 5 AND 62 skips the side-channel", + ) + // Author-less scope → skip rather than reconcile the relay's whole history. + assertNull( + withTimeout(20_000) { + client.negentropyPropagateDeletions( + relay = defaultRelayUrl, + contentFilter = Filter(kinds = listOf(1)), + localDeletions = listOf(d), + scopeAuthors = emptyList(), + download = { error("must not download") }, + upload = { error("must not upload") }, + ) + }, + "an empty author scope disables the side-channel (no relay-wide pull)", + ) } // ---- up: local has the deletion, relay does not ----------------------- @@ -136,6 +161,7 @@ class DeletionSyncTest : RelayClientTest() { relay = defaultRelayUrl, contentFilter = Filter(kinds = listOf(1)), localDeletions = listOf(deletion), + scopeAuthors = listOf(signer.pubKey), download = { error("relay has no deletions to pull") }, upload = { event -> uploaded += event @@ -177,6 +203,7 @@ class DeletionSyncTest : RelayClientTest() { relay = defaultRelayUrl, contentFilter = Filter(kinds = listOf(1)), localDeletions = emptyList(), + scopeAuthors = listOf(signer.pubKey), download = { ids: List -> // Stand-in for REQ-by-id + verify + store: pull from the // remote in-process store and ingest into the local one. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt index 7e34191495..dfe345721a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt @@ -39,26 +39,41 @@ import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent val DELETION_PROPAGATION_KINDS = listOf(DeletionEvent.KIND, RequestToVanishEvent.KIND) /** - * Whether this content [Filter] would *exclude* the deletion kinds — i.e. it pins - * `kinds` and none of them is 5/62. A filter with no `kinds` constraint already - * matches deletions, so it needs no side-channel. Anything else (a scoped `kinds` - * list without 5/62) would silently drop deletions and wants - * [deletionSideChannelFilter]. + * The deletion kinds in [deletionKinds] that this content [Filter] does NOT already + * carry — i.e. the ones a side-channel still needs to reconcile. Empty when the filter + * has no `kinds` constraint (it already matches every deletion kind) or already lists + * all of them. NIP-77 reconciles strictly by the filter's `kinds`, so listing kind 5 + * does NOT cover kind 62: each is checked independently. */ -fun Filter.excludesDeletionKinds(): Boolean { - val k = kinds ?: return false - return DELETION_PROPAGATION_KINDS.none { it in k } +fun Filter.missingDeletionKinds(deletionKinds: List = DELETION_PROPAGATION_KINDS): List { + val k = kinds ?: return emptyList() + return deletionKinds.filter { it !in k } } /** - * The companion "deletion side-channel" filter for a content [Filter]: kinds 5/62, - * scoped to the same `authors`. A kind-5/62 only affects its own author's events, so - * when the content sync is author-scoped the deletions worth reconciling are exactly - * those authors'. Carries **no** `since`/`until`: a deletion's `created_at` is when it - * was issued, not when its target was created, so inheriting the content window would - * drop a recent deletion of an old event (or an old deletion synced late). + * Whether the content [Filter] would *exclude* at least one [deletionKinds], so a + * side-channel is needed. A filter with no `kinds` constraint matches every deletion + * kind (returns false); a scoped filter that omits kind 5 and/or 62 returns true. */ -fun Filter.deletionSideChannelFilter(): Filter = Filter(kinds = DELETION_PROPAGATION_KINDS, authors = authors) +fun Filter.excludesDeletionKinds(deletionKinds: List = DELETION_PROPAGATION_KINDS): Boolean = missingDeletionKinds(deletionKinds).isNotEmpty() + +/** + * The companion "deletion side-channel" filter for a content [Filter]: the deletion + * kinds the filter doesn't already carry, scoped to [authors]. A kind-5/62 only affects + * its own author's events, so the deletions worth reconciling are exactly those + * authors' — and [authors] MUST be bounded (the content filter's authors, or, for a + * personal store, the authors actually held locally). An empty/`null` [authors] here + * means "every author on the relay", which for a personal store would pull the relay's + * ENTIRE deletion history and apply it locally — callers must not do that. + * + * Carries **no** `since`/`until`: a deletion's `created_at` is when it was issued, not + * when its target was created, so inheriting the content window would drop a recent + * deletion of an old event (or an old deletion synced late). + */ +fun Filter.deletionSideChannelFilter( + authors: List? = this.authors, + deletionKinds: List = DELETION_PROPAGATION_KINDS, +): Filter = Filter(kinds = missingDeletionKinds(deletionKinds), authors = authors) /** * Whether a local deletion-family [event] may be pushed UP to [relay]. @@ -91,35 +106,46 @@ fun shouldPropagateDeletionUp( * them makes the relay apply the deletion. Kind-62 vanishes are gated by * [shouldPropagateDeletionUp] so one is only sent to a relay it targets. * - * A no-op returning `null` when [contentFilter] already covers kinds 5/62 (an - * unscoped or already-deletion-including sync carries them itself). Otherwise runs one - * extra [negentropyReconcile] over [deletionSideChannelFilter]; the set is tiny on any - * real store, so the cost is a single short reconcile, not a second full sync. + * A no-op returning `null` when [contentFilter] already covers every [deletionKinds], + * or when [scopeAuthors] is empty (nothing to scope — an unscopeable deletion set must + * not be reconciled, or it would pull the relay's entire deletion history). + * + * **Scope is the caller's responsibility.** [scopeAuthors] bounds the reconcile — it + * MUST be a bounded author set (the content filter's authors, or the authors actually + * held locally). It defaults to `contentFilter.authors`, so an author-less content + * filter yields an EMPTY scope → this returns null rather than reconcile everything. * * The caller owns I/O: [download] fetches + ingests the given ids however it fetches * content (REQ-by-id, verify, store), and [upload] publishes one local event. Both * suspend the reconcile round that produced them, so the relay is back-pressured. * - * @param localDeletions the local kind-5/62 events — both the reconcile set and the - * source the `have` ids resolve against for [upload]. + * @param localDeletions the local deletion events (in [deletionKinds]) — both the + * reconcile set and the source the `have` ids resolve against for [upload]. + * @param scopeAuthors the authors to bound the deletion reconcile to. Empty/`null` + * disables the side-channel (see above). + * @param deletionKinds which deletion kinds to propagate (default 5 & 62). Pass `[5]` + * to propagate only precise NIP-09 deletions and skip account-wide NIP-62 vanishes. */ suspend fun INostrClient.negentropyPropagateDeletions( relay: NormalizedRelayUrl, contentFilter: Filter, localDeletions: List, + scopeAuthors: List? = contentFilter.authors, + deletionKinds: List = DELETION_PROPAGATION_KINDS, batchSize: Int = 500, idleTimeoutMs: Long = 120_000L, download: suspend (List) -> Unit, upload: suspend (Event) -> Unit, ): NegentropyReconcileResult? { - if (!contentFilter.excludesDeletionKinds()) return null + if (scopeAuthors.isNullOrEmpty()) return null + if (!contentFilter.excludesDeletionKinds(deletionKinds)) return null val byId = localDeletions.associateBy { it.id } val localEntries = localDeletions.map { IdAndTime(it.createdAt, it.id) } return negentropyReconcile( relay = relay, - filter = contentFilter.deletionSideChannelFilter(), + filter = contentFilter.deletionSideChannelFilter(authors = scopeAuthors, deletionKinds = deletionKinds), localEntries = localEntries, batchSize = batchSize, idleTimeoutMs = idleTimeoutMs, From e09a939b08ef6a84e9db9954452f5be55c58da30 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 02:29:52 +0000 Subject: [PATCH 04/12] refactor: reduce deletion sync to "send deletions for need ids", nothing else MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per the actual requirement, deletion propagation is exactly: for the ids the relay HAS that we LACK (the negentropy need set), if we hold a kind-5 deletion targeting one of them, publish that deletion up — so a note we deleted is deleted on the relay too instead of being re-downloaded. Only the need ids, only kind-5, up only. This removes all the machinery the earlier approach accreted and that the audit flagged as over-broad / data-loss-prone: - deleted NostrClientDeletionSyncExt (the bidirectional side-channel, author scoping, vanish gating, kind selection); - reverted geode MirrorWorker to base (no deletion side-channel, live-sub changes, catch-up ordering, or convergence changes); - dropped the 3-phase SyncCommand flow (deletions-first pull, author-scope derivation, reject-reaction backstop, --sync-vanish, deletions_* output). The new path pulls nothing down and applies nothing locally, so it cannot over-delete the store, and it needs no author scoping — the need set already bounds it. Kind-62 is intentionally excluded: a vanish is not "of an id". Emits deletions_sent. DeletionSyncTest now exercises the exact wiring (reconcile → look up local kind-5 by its e tag for the need ids → publish), including the negative case (a need id we never had sends nothing). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../amethyst/cli/commands/SyncCommand.kt | 166 ++++---------- .../geode/mirror/MirrorWorker.kt | 161 +++++--------- .../vitorpamplona/geode/DeletionSyncTest.kt | 204 +++++------------- .../geode/mirror/MirrorDeletionSyncTest.kt | 168 --------------- .../accessories/NostrClientDeletionSyncExt.kt | 160 -------------- 5 files changed, 152 insertions(+), 707 deletions(-) delete mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt delete mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 09ef31b197..e4da7e9c99 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -26,11 +26,7 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DELETION_PROPAGATION_KINDS import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyPropagateDeletions import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -40,7 +36,6 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** @@ -60,27 +55,13 @@ import java.util.concurrent.atomic.AtomicInteger * Pass both for a full bidirectional sync. The filter flags are the same as * `fetch`/`subscribe`; an empty filter reconciles the whole store. * - * Deletions ride a side-channel, in three phases, so both sides reflect each - * other. NIP-77 reconciles by id over the content filter, so a scoped sync - * (`--kind 1`) would never carry the kind-5 that deletes one of those notes — - * the deletion would be stuck on whichever side issued it. - * - * 1. Reconcile the missing deletion kinds — FIRST, before content, so every - * deletion is applied on both sides before the content diff is taken. The - * reconcile is **bounded to the authors we hold content for** (the filter's - * authors ∪ our local matched set's authors), never the relay's whole - * population — an author-less pull would otherwise import the relay's entire - * deletion history and mass-delete this local store. Skipped when we hold - * nothing in scope. Best-effort: a failure here never aborts the sync. - * 2. Content reconcile, over a local snapshot taken AFTER phase 1 so it never - * re-offers (and cannot resurrect on the relay) an event just deleted. - * 3. Backstop: if the relay rejected a content push — usually because it holds - * a deletion we lack — pull that author's deletions and apply them locally. - * This is the down-convergence path for author-less syncs (phase 1 skipped). - * - * Kind-5 (precise, owner-scoped) propagates by default. Kind-62 Request-to-Vanish - * mass-deletes ALL of a pubkey's events, so it is opt-in via `--sync-vanish`. - * Pass `--no-sync-deletions` to disable deletion propagation entirely. + * Deletion propagation is deliberately narrow (on by default; disable with + * `--no-sync-deletions`): for each id the relay HAS that we LACK — the reconcile's + * need set — if we hold a **kind-5** deletion that targets it, that deletion is + * published up, so a note we deleted is deleted on the relay too instead of being + * re-downloaded. That is the whole feature: only the need ids, only kind-5, up + * only. Nothing is pulled down or applied locally, so it can never over-delete this + * store, and it needs no author scoping (the need set already bounds it). * * Both directions are pipelined with the reconcile: need-id batches feed * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single @@ -121,86 +102,24 @@ object SyncCommand { // Default direction is download; --up adds upload. val up = args.bool("up") val down = args.bool("down") || !up + // Deletion propagation (on by default; --no-sync-deletions disables). Scope is + // exactly: for the ids the relay HAS that we LACK (the reconcile's need set), if + // we hold a kind-5 deletion targeting one of them, publish that deletion up so + // the relay deletes it too — instead of re-downloading a note we deleted. That + // is the whole feature: only these ids, only kind-5, up only. Nothing is pulled + // down or applied locally, so it can never over-delete this store. val syncDeletions = !args.bool("no-sync-deletions") - // Which deletion kinds the side-channel carries. Kind-5 (precise, owner-scoped) - // by default; kind-62 Request-to-Vanish is opt-in because it mass-deletes ALL of - // a pubkey's events, a blast radius that always exceeds a content sync's scope. - val deletionKinds = if (args.bool("sync-vanish")) DELETION_PROPAGATION_KINDS else listOf(DeletionEvent.KIND) val filter = RawEventSupport.buildFilter(args) Context.open(dataDir).use { ctx -> ctx.prepare() - - val deletionsDown = AtomicInteger(0) - val deletionsUp = AtomicInteger(0) - var deletionsError: String? = null - - // ── Phase 1: deletions first, both directions ──────────────────── - // Propagate deletions before content so both stores have applied every - // deletion by the time content is diffed. Ordering is load-bearing: a - // deletion pulled down here removes a local event, so the content snapshot - // MUST be taken AFTER this phase — a snapshot taken before would still list - // the just-deleted event and re-offer it up, resurrecting it on a relay - // that also lacks the deletion. - // - // SCOPE. The reconcile is bounded to the authors we actually hold content - // for (the content filter's authors ∪ the authors in our local matched - // set) — NEVER the relay's whole population. An author-less filter against a - // public relay would otherwise pull the relay's entire deletion history and - // apply it to this personal store, mass-deleting cached events far outside - // the sync's scope. When we hold nothing in scope there is nothing to - // reconcile, so the side-channel is skipped and Phase 3 covers the rest. - // Only compute the scope (a store read) when a side-channel could actually - // run — a whole-store sync already carries deletions and must not pay a - // full-store scan here. - val runSideChannel = syncDeletions && filter.excludesDeletionKinds(deletionKinds) - val scopeAuthors = - if (runSideChannel) { - ((filter.authors ?: emptyList()) + ctx.store.query(filter).map { it.pubKey }).distinct() - } else { - emptyList() - } - val deletionResult = - if (runSideChannel && scopeAuthors.isNotEmpty()) { - // Best-effort: a deletion-reconcile failure must NEVER abort the - // primary content sync (matches geode's mirror policy). Record it - // and fall through to content + the Phase-3 backstop. - try { - val localDeletions = ctx.store.query(filter.deletionSideChannelFilter(scopeAuthors, deletionKinds)) - ctx.client.negentropyPropagateDeletions( - relay = relay, - contentFilter = filter, - localDeletions = localDeletions, - scopeAuthors = scopeAuthors, - deletionKinds = deletionKinds, - idleTimeoutMs = timeoutMs, - download = { batch -> - deletionsDown.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) - }, - upload = { event -> - if (ctx.publish(event, setOf(relay)).values.any { it }) deletionsUp.incrementAndGet() - }, - ) - } catch (e: NegentropySyncException) { - deletionsError = e.message ?: "deletion sync failed" - null - } - } else { - null - } - - // ── Phase 2: content ───────────────────────────────────────────── - // Snapshot the local set AFTER the deletion phase so it reflects any - // deletion just applied — never re-offering an event we just deleted. val localEvents = ctx.store.query(filter) val localById = localEvents.associateBy { it.id } val localEntries = localEvents.map { IdAndTime(it.createdAt, it.id) } val downloaded = AtomicInteger(0) val uploaded = AtomicInteger(0) - // Authors whose content push the relay rejected — a rejection usually - // means the relay holds a deletion we lack (Phase 3 reconciles them). - val blockedAuthors = ConcurrentHashMap.newKeySet() + val deletionsSent = AtomicInteger(0) val result = try { @@ -212,6 +131,8 @@ object SyncCommand { // Unbounded is fine here: have-ids reference events we already // hold locally, so memory is bounded by the local set. val haveBatches = Channel>(Channel.UNLIMITED) + // need-ids routed to the deletion sender (bounded → back-pressure). + val delBatches = Channel>(DOWNLOAD_WORKERS * 2) val downloaders = List(DOWNLOAD_WORKERS) { @@ -227,15 +148,24 @@ object SyncCommand { for (batch in haveBatches) { for (id in batch) { val ev = localById[id] ?: continue - val ack = ctx.publish(ev, setOf(relay)) - if (ack.values.any { it }) { - uploaded.incrementAndGet() - } else if (syncDeletions) { - // Relay refused it — most often because it holds a - // deletion for this id that we lack. Remember the - // author so Phase 3 can pull that deletion down. - blockedAuthors.add(ev.pubKey) - } + if (ctx.publish(ev, setOf(relay)).values.any { it }) uploaded.incrementAndGet() + } + } + } + // For each id the relay has that we lack, publish any local kind-5 + // deletion that targets it (queried by its `e` tag). A note we + // deleted then gets deleted on the relay too, instead of being + // re-downloaded. Most need-ids have no such deletion, so the query + // usually returns empty and nothing is sent. + val deletionSender = + launch { + for (batch in delBatches) { + val mine = + ctx.store.query( + Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to batch)), + ) + for (del in mine) { + if (ctx.publish(del, setOf(relay)).values.any { it }) deletionsSent.incrementAndGet() } } } @@ -250,36 +180,26 @@ object SyncCommand { idleTimeoutMs = timeoutMs, reconcileConcurrency = RECONCILE_CONCURRENCY, onHaveIds = if (up) { batch -> haveBatches.send(batch) } else null, - onNeedIds = { batch -> if (down) needBatches.send(batch) }, + onNeedIds = { batch -> + if (down) needBatches.send(batch) + if (syncDeletions) delBatches.send(batch) + }, ) } finally { needBatches.close() haveBatches.close() + delBatches.close() } downloaders.joinAll() uploader.join() + deletionSender.join() reconcile } } catch (e: NegentropySyncException) { return Output.error("sync_error", e.message ?: "negentropy sync failed") } - // ── Phase 3: reject-reaction backstop ──────────────────────────── - // A content push the relay blocked usually means the relay deleted that - // id and holds the deletion we lack. Pull that author's deletions and - // ingest them locally so we stop re-offering the dead event. Cheap and - // precisely bounded — only fires on an actual rejection, only for the - // rejected authors, and verify-by-fetch: only a real deletion the store - // accepts has any effect. This is the primary down-convergence path for - // author-less syncs (where Phase 1 is intentionally skipped). - if (syncDeletions && blockedAuthors.isNotEmpty()) { - ctx.drain( - mapOf(relay to listOf(Filter(kinds = deletionKinds, authors = blockedAuthors.toList()))), - timeoutMs, - ) - } - Output.emit( mapOf( "relay" to relay.url, @@ -289,11 +209,7 @@ object SyncCommand { "have" to result.haveCount, "downloaded" to downloaded.get(), "uploaded" to uploaded.get(), - "deletions_need" to (deletionResult?.needCount ?: 0), - "deletions_have" to (deletionResult?.haveCount ?: 0), - "deletions_downloaded" to deletionsDown.get(), - "deletions_uploaded" to deletionsUp.get(), - "deletions_error" to (deletionsError ?: ""), + "deletions_sent" to deletionsSent.get(), ), ) return 0 diff --git a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt index ae621a5415..62936d17fe 100644 --- a/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt +++ b/geode/src/main/kotlin/com/vitorpamplona/geode/mirror/MirrorWorker.kt @@ -23,11 +23,8 @@ package com.vitorpamplona.geode.mirror import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.relay.client.NostrClient -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySyncOrFetch -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.shouldPropagateDeletionUp import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.commands.toClient.EventMessage import com.vitorpamplona.quartz.nip01Core.relay.commands.toRelay.ReqCmd @@ -242,24 +239,17 @@ class MirrorWorker( val listener: SubscriptionListener, initialSince: Long, val watermark: AtomicLong, - // The deletion side-channel filter (kinds 5/62), when the operator scope - // excludes them. Carried here so it rides every re-subscribe too — else a - // reconnect would silently drop live deletions. - val deletionScope: Filter?, ) { @Volatile var issuedSince: Long = initialSince - /** Content scope plus, when in force, the deletion side-channel — both at [since]. */ - fun filtersFrom(since: Long): List = listOfNotNull(scopedBase.copy(since = since), deletionScope?.copy(since = since)) - fun advanceSinceOnReconnect() { val candidate = watermark.get() - WATERMARK_OVERLAP_SECS if (candidate > issuedSince) { issuedSince = candidate client.subscribe( subId = subId, - filters = mapOf(up.url to filtersFrom(candidate)), + filters = mapOf(up.url to listOf(scopedBase.copy(since = candidate))), listener = listener, ) sinceAdvances.incrementAndGet() @@ -340,15 +330,6 @@ class MirrorWorker( val scopedBase = (up.filter ?: Filter()).copy(since = null, limit = null) val initialSince = since - up.backfillSeconds - // Deletion side-channel scope. NIP-77 (and the live REQ) reconcile by - // id over the operator filter, so a kind-scoped mirror (`kinds:[1]`) - // would drop the kind-5/62 that deletes one of those notes — the - // deletion would never reach the other side. When the operator filter - // excludes 5/62, mirror them on their own (same authors), in the - // mirror's configured direction. `null` when the filter already covers - // deletions (unscoped, or 5/62 explicitly listed) — nothing extra to do. - val deletionScope = up.filter?.takeIf { it.excludesDeletionKinds() }?.deletionSideChannelFilter() - // strfry's two-phase model, both directions (`strfry sync --dir // both` + the live router): a one-shot NIP-77 "sync" closes the // historical [initialSince, now] gap — down pulls what the upstream @@ -363,16 +344,16 @@ class MirrorWorker( val upLiveSince = if (catchUpUp) since else initialSince if (up.direction != MirrorDirection.UP) { - downSubs += startDown(i, up, scopedBase, downLiveSince, exchanged, deletionScope) + downSubs += startDown(i, up, scopedBase, downLiveSince, exchanged) } if (up.direction != MirrorDirection.DOWN) { - startUp(up, scopedBase.copy(since = upLiveSince), exchanged, deletionScope?.copy(since = upLiveSince)) + startUp(up, scopedBase.copy(since = upLiveSince), exchanged) } if (catchUpDown) { - scope.launch { runCatchUpDown(up, scopedBase, initialSince, since, deletionScope) } + scope.launch { runCatchUpDown(up, scopedBase, initialSince, since) } } if (catchUpUp) { - scope.launch { runCatchUpUp(up, scopedBase, initialSince, since, exchanged, deletionScope) } + scope.launch { runCatchUpUp(up, scopedBase, initialSince, since, exchanged) } } } client.connect() @@ -430,14 +411,12 @@ class MirrorWorker( scopedBase: Filter, initialSince: Long, until: Long, - deletionScope: Filter?, ) { val catchUpFilter = scopedBase.copy(since = initialSince, until = until) - - // Even a trusted upstream may only inject events inside the declared - // scope — plus the deletion side-channel scope when one is in force, so a - // kind-scoped mirror still accepts the kind-5/62 that apply to it. - fun inScope(event: Event): Boolean = up.filter == null || up.filter.match(event) || deletionScope?.match(event) == true + // Reconcile against what we already hold in this window → download only + // the diff (like `strfry sync`). No store wired → empty local set → the + // whole window is downloaded and the store's unique-id constraint dedups. + val localEntries = store?.snapshotIdsForNegentropy(listOf(catchUpFilter)) ?: emptyList() // Bounded hand-off → one ingest consumer. `onEvent` can't suspend, so it // blocks here when the sink falls behind; because negentropySyncOrFetch's @@ -463,34 +442,26 @@ class MirrorWorker( } } - // Reconciles one filter against what we already hold → downloads only the - // diff (like `strfry sync`). No store wired → empty local set → the whole - // set is downloaded and the store's unique-id constraint dedups. - suspend fun pull(filter: Filter) { - val localEntries = store?.snapshotIdsForNegentropy(listOf(filter)) ?: emptyList() + try { val result = client.negentropySyncOrFetch( relay = up.url, - filter = filter, + filter = catchUpFilter, localEntries = localEntries, onEvent = { event -> - if (inScope(event)) handoff.trySendBlocking(event) else filtered.incrementAndGet() + // Same containment as the live path: even a trusted + // upstream may only inject events inside the declared scope. + if (up.filter == null || up.filter.match(event)) { + handoff.trySendBlocking(event) + } else { + filtered.incrementAndGet() + } }, ) Log.i("MirrorWorker") { val how = if (result.pagedFallback) "paged REQ (upstream has no NIP-77)" else "negentropy" "catch-up from ${up.url.url}: ${result.downloaded} events via $how" } - } - - try { - // Deletions first, so a deletion already lands (or the reject-trigger - // is armed) before the content pull can add its target — no - // add-then-delete churn. They carry no time window: a deletion's - // created_at is when it was issued, not when its target was, so the - // whole deletion set for the scope is reconciled, not the window. - if (deletionScope != null) pull(deletionScope) - pull(catchUpFilter) } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -518,80 +489,65 @@ class MirrorWorker( initialSince: Long, until: Long, exchanged: RecentIds?, - deletionScope: Filter?, ) { val localStore = store ?: return val catchUpFilter = scopedBase.copy(since = initialSince, until = until) - - // Reconcile [filter] against the upstream and PUSH the events we hold that - // it lacks (the `have` ids), re-reconciling each round until the diff is - // empty — the reconcile is the delivery check, so the push converges to - // lossless. [publishable] gates which local events actually go: scope - // containment for content, and per-relay vanish targeting for kind-62 (a - // vanish is only sent to a relay it names). - suspend fun pushUp( - filter: Filter, - label: String, - publishable: (Event) -> Boolean, - ) { - // Our local set for this window is fixed; each round reconciles it - // against the upstream, which grows as we push, so the diff shrinks. - val localEntries = localStore.snapshotIdsForNegentropy(listOf(filter)) + // Our local set for this window is fixed; each round reconciles it + // against the upstream, which grows as we push, so the `have` diff + // shrinks to zero. + val localEntries = localStore.snapshotIdsForNegentropy(listOf(catchUpFilter)) + try { var round = 0 while (round < MAX_UP_SYNC_ROUNDS) { - // Stream the `have` batches and publish as they arrive — never - // materialising the full diff. Publishing suspends the round, so - // the relay is back-pressured. The `need` direction is the down - // catch-up's job, so its ids are discarded here. + // Reconcile and PUBLISH the `have` ids (events we hold the + // upstream lacks) as each batch streams in — never materialising + // the full diff. On a large window the id list is millions of + // entries; the streaming reconcile keeps memory at one batch and + // still back-pressures the relay because publishing suspends the + // round. The `need` direction is the down catch-up's job, so its + // ids are discarded (not accumulated) here. var haveCount = 0 var pushed = 0 client.negentropyReconcile( relay = up.url, - filter = filter, + filter = catchUpFilter, localEntries = localEntries, batchSize = HAVE_FETCH_BATCH, onHaveIds = { batch -> haveCount += batch.size for (event in localStore.query(Filter(ids = batch))) { - if (!publishable(event)) continue - // Echo suppression: record the id so a BOTH mirror - // doesn't re-ingest its own push on the down sub (but - // always re-publish — a straggler stays in `exchanged` - // yet still needs delivering). + // Scope containment: a scoped upstream only receives + // in-scope events. Echo suppression: record the id so + // a BOTH mirror doesn't re-ingest its own push on the + // down sub (but always re-publish — a straggler stays + // in `exchanged` yet still needs delivering). + if (up.filter != null && !up.filter.match(event)) continue exchanged?.add(event.id) client.publish(event, setOf(up.url)) pushed++ } - delay(UP_PUBLISH_PACING_MS) // pace the outbox + // Pace the outbox so a batch drains before the next. + delay(UP_PUBLISH_PACING_MS) }, onNeedIds = { }, ) - // Converge when nothing PUBLISHABLE remains to push — not on raw - // haveCount. A diff that is all un-publishable (e.g. a kind-62 vanish - // for a relay this upstream isn't, which shouldPropagateDeletionUp - // rightly refuses) would otherwise report a non-zero haveCount every - // round and burn all MAX_UP_SYNC_ROUNDS on every startup. - if (pushed == 0) { - val how = if (haveCount == 0) "converged" else "converged ($haveCount un-publishable left)" - Log.i("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: $how after $round round(s)" } + if (haveCount == 0) { + Log.i("MirrorWorker") { "up catch-up to ${up.url.url}: converged after $round round(s)" } return } + // `client.publish`'s outbox is best-effort under a bulk burst + // (each publish also churns a reconnect), so instead of trusting + // one pass we re-reconcile next round and re-push only what didn't + // land — the reconcile is the delivery check, so the push + // converges to lossless. sentUp.addAndGet(pushed.toLong()) - Log.i("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: round $round pushed $pushed (had $haveCount to go)" } + Log.i("MirrorWorker") { "up catch-up to ${up.url.url}: round $round pushed $pushed (had $haveCount to go)" } round++ - delay(UP_SYNC_SETTLE_MS) // let the upstream ingest + OK before re-checking + // Let the upstream ingest + OK before the next reconcile, so the + // diff reflects what actually landed rather than what's in flight. + delay(UP_SYNC_SETTLE_MS) } - Log.w("MirrorWorker") { "up catch-up ($label) to ${up.url.url}: did not fully converge in $MAX_UP_SYNC_ROUNDS rounds (live push continues)" } - } - - try { - // Deletions first: push a deletion up before its target, so the - // upstream's reject-trigger blocks the target instead of ingesting - // then deleting it. - if (deletionScope != null) { - pushUp(deletionScope, "deletions") { event -> shouldPropagateDeletionUp(event, up.url) } - } - pushUp(catchUpFilter, "content") { event -> up.filter == null || up.filter.match(event) } + Log.w("MirrorWorker") { "up catch-up to ${up.url.url}: did not fully converge in $MAX_UP_SYNC_ROUNDS rounds (live push continues)" } } catch (e: CancellationException) { throw e } catch (e: Throwable) { @@ -606,7 +562,6 @@ class MirrorWorker( scopedBase: Filter, initialSince: Long, exchanged: RecentIds?, - deletionScope: Filter?, ): DownSub { // watermark tracks the newest created_at ingested from this // upstream; seeded at initialSince so a still-catching-up @@ -638,7 +593,7 @@ class MirrorWorker( // upstream can only inject events the operator // declared — the REQ shapes what we ask for, this // shapes what we accept. - if (up.filter != null && !up.filter.match(event) && deletionScope?.match(event) != true) { + if (up.filter != null && !up.filter.match(event)) { filtered.incrementAndGet() Log.d("MirrorWorker") { "out-of-scope from ${relay.url}: ${event.id}" } return @@ -661,13 +616,12 @@ class MirrorWorker( } } val subId = "geode-mirror-$index" - val downSub = DownSub(subId, up, scopedBase, listener, initialSince, watermark, deletionScope) client.subscribe( subId = subId, - filters = mapOf(up.url to downSub.filtersFrom(initialSince)), + filters = mapOf(up.url to listOf(scopedBase.copy(since = initialSince))), listener = listener, ) - return downSub + return DownSub(subId, up, scopedBase, listener, initialSince, watermark) } /** @@ -684,7 +638,6 @@ class MirrorWorker( up: MirrorUpstream, scopedFilter: Filter, exchanged: RecentIds?, - deletionScope: Filter?, ) { val session = server.connect { json -> @@ -692,9 +645,6 @@ class MirrorWorker( val event = runCatching { (OptimizedJsonMapper.fromJsonToMessage(json) as? EventMessage)?.event } .getOrNull() ?: return@connect - // A kind-62 vanish only goes to a relay it targets; kind-5 always - // goes. Content events are already scoped by the session's REQ. - if (!shouldPropagateDeletionUp(event, up.url)) return@connect // BOTH: don't push back what we just pulled down. if (exchanged?.contains(event.id) == true) return@connect exchanged?.add(event.id) @@ -702,9 +652,8 @@ class MirrorWorker( sentUp.incrementAndGet() } upSessions += AutoCloseable { session.close() } - val reqFilters = listOfNotNull(scopedFilter, deletionScope) scope.launch { - session.receive(OptimizedJsonMapper.toJson(ReqCmd("geode-mirror-up", reqFilters))) + session.receive(OptimizedJsonMapper.toJson(ReqCmd("geode-mirror-up", listOf(scopedFilter)))) } } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt index e510aaf801..891a4426a9 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -24,37 +24,30 @@ import com.vitorpamplona.geode.testing.RelayClientTest import com.vitorpamplona.geode.testing.preload import com.vitorpamplona.geode.testing.publish import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.deletionSideChannelFilter -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.excludesDeletionKinds -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyPropagateDeletions -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.shouldPropagateDeletionUp +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout import kotlin.test.Test import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull import kotlin.test.assertTrue /** - * A NIP-77 sync reconciles by id over the content filter, so a scoped sync - * (`--kind 1`) never carries the kind-5/62 that would delete one of those notes - * — the deletion is stuck on whichever side issued it. The deletion side-channel - * ([negentropyPropagateDeletions]) closes that gap by reconciling kinds 5 & 62 - * on their own, both directions, independent of the content filter. + * The `amy sync` deletion rule, end-to-end: for the ids the relay HAS that we LACK + * (the negentropy need set), if we hold a kind-5 deletion targeting one of them, + * publish that deletion up so the relay deletes it too — instead of re-downloading a + * note we deleted. Only these ids, only kind-5, up only; nothing is pulled down or + * applied locally, so the personal store can never be over-deleted. * - * Scenario under test (the user's "Relay A has a deletion, Relay B doesn't"): - * the note lives on both sides; a kind-5 deleting it lives on only one. After the - * side-channel runs, the deletion has reached the other side and the note is gone - * there too. + * This exercises the exact wiring `SyncCommand` uses (reconcile → look up the local + * kind-5 by its `e` tag for the need ids → publish), which the CLI itself has no test + * harness for. */ class DeletionSyncTest : RelayClientTest() { private val signer = NostrSignerSync(KeyPair()) @@ -63,159 +56,74 @@ class DeletionSyncTest : RelayClientTest() { private fun deletionOf(target: Event): Event = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) - // ---- filter helpers (pure) -------------------------------------------- - - @Test - fun excludesDeletionKindsChecksEachKindIndependently() { - // No kinds constraint already matches every deletion kind. - assertFalse(Filter().excludesDeletionKinds()) - // Listing ONE deletion kind does NOT cover the other — the reconcile - // carries only the kinds actually listed. - assertTrue(Filter(kinds = listOf(1, 5)).excludesDeletionKinds(), "kind 5 listed, kind 62 still missing") - assertTrue(Filter(kinds = listOf(62)).excludesDeletionKinds(), "kind 62 listed, kind 5 still missing") - assertTrue(Filter(kinds = listOf(1)).excludesDeletionKinds(), "kind-1-only misses both") - // Both listed → nothing missing. - assertFalse(Filter(kinds = listOf(5, 62)).excludesDeletionKinds(), "both deletion kinds covered") - // With a restricted deletionKinds set, only kind 5 matters. - assertFalse(Filter(kinds = listOf(1, 5)).excludesDeletionKinds(listOf(DeletionEvent.KIND))) - } - - @Test - fun sideChannelFilterCarriesMissingKindsScopedAuthorsNoWindow() { - val authored = Filter(kinds = listOf(1, 5), authors = listOf("aa", "bb"), since = 100, until = 200) - val side = authored.deletionSideChannelFilter(authors = listOf("aa", "bb")) - - assertEquals(listOf(RequestToVanishEvent.KIND), side.kinds, "kind 5 already covered → only 62 missing") - assertEquals(listOf("aa", "bb"), side.authors, "explicit author scope") - assertNull(side.since, "no time window: a deletion's created_at is not its target's") - assertNull(side.until) - } - - @Test - fun vanishGateHonorsDeclaredTargets() { - val here = defaultRelayUrl - val elsewhere = RelayUrlNormalizer.normalize("wss://elsewhere.example/") - - val delete = deletionOf(note("x")) - val vanishHere = signer.sign(RequestToVanishEvent.build(here)) - val vanishElsewhere = signer.sign(RequestToVanishEvent.build(elsewhere)) - val vanishEverywhere = signer.sign(RequestToVanishEvent.buildVanishFromEverywhere()) - - assertTrue(shouldPropagateDeletionUp(delete, elsewhere), "kind-5 is always safe to propagate") - assertTrue(shouldPropagateDeletionUp(vanishHere, here), "vanish targeting this relay goes") - assertFalse(shouldPropagateDeletionUp(vanishElsewhere, here), "vanish for another relay does not") - assertTrue(shouldPropagateDeletionUp(vanishEverywhere, here), "ALL_RELAYS vanish goes anywhere") - } - - @Test - fun noOpWhenAllKindsCoveredOrScopeEmpty() = - runBlocking { - val d = deletionOf(note("x")) - // All deletion kinds already covered by content → skip. - assertNull( - withTimeout(20_000) { - client.negentropyPropagateDeletions( - relay = defaultRelayUrl, - contentFilter = Filter(kinds = listOf(5, 62)), - localDeletions = listOf(d), - scopeAuthors = listOf(signer.pubKey), - download = { error("must not download") }, - upload = { error("must not upload") }, - ) - }, - "a filter that already covers 5 AND 62 skips the side-channel", - ) - // Author-less scope → skip rather than reconcile the relay's whole history. - assertNull( - withTimeout(20_000) { - client.negentropyPropagateDeletions( - relay = defaultRelayUrl, - contentFilter = Filter(kinds = listOf(1)), - localDeletions = listOf(d), - scopeAuthors = emptyList(), - download = { error("must not download") }, - upload = { error("must not upload") }, - ) - }, - "an empty author scope disables the side-channel (no relay-wide pull)", - ) + /** The SyncCommand step under test: publish local kind-5 deletions targeting [needIds]. */ + private suspend fun sendDeletionsFor( + local: com.vitorpamplona.geode.RelayEngine, + needIds: List, + ): Int { + var sent = 0 + val mine = local.store.query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to needIds))) + for (del in mine) { + defaultRelay.publish(del) + sent++ } - - // ---- up: local has the deletion, relay does not ----------------------- + return sent + } @Test - fun pushesLocalDeletionUpSoRelayRemovesTarget() = + fun sendsDeletionForANeedIdWeDeleted() = runBlocking { val note = note("delete me") val deletion = deletionOf(note) - // Relay B: has the note, no deletion. + // Relay has the note (no deletion). defaultRelay.preload(listOf(note)) assertEquals(1, defaultRelay.store.query(Filter(ids = listOf(note.id))).size) - // Local side (Relay A) already applied the deletion, so it holds only - // the kind-5. A content sync over kind 1 would never carry it. - val uploaded = mutableListOf() - withTimeout(20_000) { - client.negentropyPropagateDeletions( - relay = defaultRelayUrl, - contentFilter = Filter(kinds = listOf(1)), - localDeletions = listOf(deletion), - scopeAuthors = listOf(signer.pubKey), - download = { error("relay has no deletions to pull") }, - upload = { event -> - uploaded += event - defaultRelay.publish(event) - }, - ) - } + // Local already applied the deletion → it holds only the kind-5, so the + // note is a "need" (relay has it, we lack it). + val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local/")) + local.preload(listOf(note, deletion)) + assertTrue(local.store.query(Filter(ids = listOf(note.id))).isEmpty(), "local deleted the note") - assertEquals(listOf(deletion.id), uploaded.map { it.id }, "the deletion was pushed up") + val localKind1 = local.store.query(Filter(kinds = listOf(1))).map { IdAndTime(it.createdAt, it.id) } + val diff = + withTimeout(20_000) { + client.negentropyReconcileIds(relay = defaultRelayUrl, filter = Filter(kinds = listOf(1)), localEntries = localKind1) + } + assertEquals(setOf(note.id), diff.needIds.toSet(), "the deleted note is the only need id") + + val sent = sendDeletionsFor(local, diff.needIds) + + assertEquals(1, sent, "the deletion targeting the need id was sent") assertTrue( defaultRelay.store.query(Filter(ids = listOf(note.id))).isEmpty(), "relay applied the pushed deletion and removed the note", ) } - // ---- down: relay has the deletion, local does not --------------------- - @Test - fun pullsRelayDeletionDownSoLocalRemovesTarget() = + fun sendsNothingForANeedIdWeNeverHad() = runBlocking { - val note = note("delete me too") - val deletion = deletionOf(note) + // Relay has a note we simply never had and never deleted — a plain download, + // no deletion to send. + val other = note("just never had this") + defaultRelay.preload(listOf(other)) - // Remote relay already applied the deletion → holds only the kind-5. - defaultRelay.preload(listOf(note, deletion)) - assertTrue( - defaultRelay.store.query(Filter(ids = listOf(note.id))).isEmpty(), - "precondition: relay removed the note when it ingested the deletion", - ) + val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local2/")) + // Local holds an unrelated deletion (targets a different note) — must NOT be + // sent for `other`. + local.preload(listOf(deletionOf(note("unrelated")))) - // Local side: a second store still holding the note, no deletion. - val localUrl = RelayUrlNormalizer.normalize("ws://local-a/") - val local = hub.getOrCreate(localUrl) - local.preload(listOf(note)) - assertEquals(1, local.store.query(Filter(ids = listOf(note.id))).size) + val diff = + withTimeout(20_000) { + client.negentropyReconcileIds(relay = defaultRelayUrl, filter = Filter(kinds = listOf(1)), localEntries = emptyList()) + } + assertTrue(other.id in diff.needIds, "the note is a need id") - withTimeout(20_000) { - client.negentropyPropagateDeletions( - relay = defaultRelayUrl, - contentFilter = Filter(kinds = listOf(1)), - localDeletions = emptyList(), - scopeAuthors = listOf(signer.pubKey), - download = { ids: List -> - // Stand-in for REQ-by-id + verify + store: pull from the - // remote in-process store and ingest into the local one. - defaultRelay.store.query(Filter(ids = ids)).forEach { local.store.insert(it) } - }, - upload = { error("local has no deletions to push") }, - ) - } + val sent = sendDeletionsFor(local, diff.needIds) - assertTrue( - local.store.query(Filter(ids = listOf(note.id))).isEmpty(), - "local store applied the pulled deletion and removed the note", - ) + assertEquals(0, sent, "no deletion targets the need id, so nothing is sent") + assertEquals(1, defaultRelay.store.query(Filter(ids = listOf(other.id))).size, "the note is untouched on the relay") } } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt deleted file mode 100644 index 7d37ee8389..0000000000 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/mirror/MirrorDeletionSyncTest.kt +++ /dev/null @@ -1,168 +0,0 @@ -/* - * 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.geode.mirror - -import com.vitorpamplona.geode.KtorRelay -import com.vitorpamplona.geode.RelayEngine -import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.normalizeRelayUrl -import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync -import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent -import kotlinx.coroutines.delay -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull -import kotlin.test.AfterTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -/** - * A kind-scoped mirror (`filter = {kinds:[1]}`) must still propagate the kind-5/62 - * that delete those notes — otherwise the deletion is stuck upstream and the note - * lives forever on the mirror. [MirrorWorker]'s deletion side-channel reconciles - * kinds 5/62 on their own, in the mirror's configured direction, independent of - * the operator filter. - */ -class MirrorDeletionSyncTest { - private val upstreamStore = EventStore(null) - private val downstreamStore = EventStore(null) - - private val upstream = RelayEngine(url = "ws://127.0.0.1:7896/".normalizeRelayUrl(), store = upstreamStore) - private val downstream = - RelayEngine(url = "ws://127.0.0.1:7897/".normalizeRelayUrl(), store = downstreamStore, parallelVerify = true) - - private var server: KtorRelay? = null - private var worker: MirrorWorker? = null - - @AfterTest - fun tearDown() { - worker?.close() - server?.stop(gracePeriodMillis = 0, timeoutMillis = 1_000) - upstream.close() - downstream.close() - } - - @Test - fun scopedDownMirrorPropagatesDeletion() = - runBlocking { - val signer = NostrSignerSync(KeyPair()) - val note = signer.sign(TextNoteEvent.build("delete me")) - val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) - - // Upstream already applied the deletion → it holds only the kind-5. - upstreamStore.insert(note) - upstreamStore.insert(deletion) - assertEquals(0, upstreamStore.count(Filter(ids = listOf(note.id))), "upstream removed the note") - - // Downstream (the mirror) still holds the note, no deletion. - downstreamStore.insert(note) - assertEquals(1, downstreamStore.count(Filter(ids = listOf(note.id))), "mirror starts with the note") - - server = KtorRelay(upstream, host = "127.0.0.1", port = 7896).start() - - worker = - MirrorWorker( - upstreams = - listOf( - MirrorUpstream( - url = "ws://127.0.0.1:7896/".normalizeRelayUrl(), - trusted = true, - backfillSeconds = 86_400, - // Scoped to kind 1 — would drop the kind-5 without the side-channel. - filter = Filter(kinds = listOf(1)), - ), - ), - server = downstream.server, - store = downstreamStore, - negentropyBackfill = true, - ).also { it.start() } - - val gone = - withTimeoutOrNull(30_000) { - while (downstreamStore.count(Filter(ids = listOf(note.id))) > 0) delay(200) - true - } - - assertTrue(gone == true, "scoped down mirror did not propagate the deletion") - assertEquals( - 1, - downstreamStore.count(Filter(kinds = listOf(DeletionEvent.KIND))), - "mirror ingested the deletion event itself", - ) - } - - /** - * The authoritative-push case: the local relay holds the deletion (its note - * already removed), the remote still holds the note, and a scoped `dir = up` - * mirror must push the kind-5 up so the remote reflects the local state. - */ - @Test - fun scopedUpMirrorPushesDeletion() = - runBlocking { - val signer = NostrSignerSync(KeyPair()) - val note = signer.sign(TextNoteEvent.build("delete me")) - val deletion = signer.sign(DeletionEvent.build(listOf(note), createdAt = note.createdAt + 1)) - - // Local (downstream) is authoritative: it already applied the deletion. - downstreamStore.insert(note) - downstreamStore.insert(deletion) - assertEquals(0, downstreamStore.count(Filter(ids = listOf(note.id))), "local removed its note") - - // Remote (upstream, the sink geode dials) still holds the note. - upstreamStore.insert(note) - assertEquals(1, upstreamStore.count(Filter(ids = listOf(note.id))), "remote still has the note") - - server = KtorRelay(upstream, host = "127.0.0.1", port = 7896).start() - - worker = - MirrorWorker( - upstreams = - listOf( - MirrorUpstream( - url = "ws://127.0.0.1:7896/".normalizeRelayUrl(), - trusted = false, - backfillSeconds = 86_400, - direction = MirrorDirection.UP, - filter = Filter(kinds = listOf(1)), - ), - ), - server = downstream.server, - store = downstreamStore, - negentropyBackfill = true, - ).also { it.start() } - - val gone = - withTimeoutOrNull(30_000) { - while (upstreamStore.count(Filter(ids = listOf(note.id))) > 0) delay(200) - true - } - - assertTrue(gone == true, "scoped up mirror did not push the deletion to the remote") - assertEquals( - 1, - upstreamStore.count(Filter(kinds = listOf(DeletionEvent.KIND))), - "remote received the deletion event", - ) - } -} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt deleted file mode 100644 index dfe345721a..0000000000 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientDeletionSyncExt.kt +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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.quartz.nip01Core.relay.client.accessories - -import com.vitorpamplona.quartz.nip01Core.core.Event -import com.vitorpamplona.quartz.nip01Core.core.HexKey -import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.store.IdAndTime -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent -import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent - -/** - * Event kinds that carry *deletion intent* — NIP-09 deletion requests (kind 5) - * and NIP-62 request-to-vanish (kind 62). They are propagation instructions, not - * content, so a sync should carry them **regardless of its content filter**: a - * `--kind 1` sync that dropped the kind-5 deleting one of those notes would leave - * the note un-deleted on the other side forever. - */ -val DELETION_PROPAGATION_KINDS = listOf(DeletionEvent.KIND, RequestToVanishEvent.KIND) - -/** - * The deletion kinds in [deletionKinds] that this content [Filter] does NOT already - * carry — i.e. the ones a side-channel still needs to reconcile. Empty when the filter - * has no `kinds` constraint (it already matches every deletion kind) or already lists - * all of them. NIP-77 reconciles strictly by the filter's `kinds`, so listing kind 5 - * does NOT cover kind 62: each is checked independently. - */ -fun Filter.missingDeletionKinds(deletionKinds: List = DELETION_PROPAGATION_KINDS): List { - val k = kinds ?: return emptyList() - return deletionKinds.filter { it !in k } -} - -/** - * Whether the content [Filter] would *exclude* at least one [deletionKinds], so a - * side-channel is needed. A filter with no `kinds` constraint matches every deletion - * kind (returns false); a scoped filter that omits kind 5 and/or 62 returns true. - */ -fun Filter.excludesDeletionKinds(deletionKinds: List = DELETION_PROPAGATION_KINDS): Boolean = missingDeletionKinds(deletionKinds).isNotEmpty() - -/** - * The companion "deletion side-channel" filter for a content [Filter]: the deletion - * kinds the filter doesn't already carry, scoped to [authors]. A kind-5/62 only affects - * its own author's events, so the deletions worth reconciling are exactly those - * authors' — and [authors] MUST be bounded (the content filter's authors, or, for a - * personal store, the authors actually held locally). An empty/`null` [authors] here - * means "every author on the relay", which for a personal store would pull the relay's - * ENTIRE deletion history and apply it locally — callers must not do that. - * - * Carries **no** `since`/`until`: a deletion's `created_at` is when it was issued, not - * when its target was created, so inheriting the content window would drop a recent - * deletion of an old event (or an old deletion synced late). - */ -fun Filter.deletionSideChannelFilter( - authors: List? = this.authors, - deletionKinds: List = DELETION_PROPAGATION_KINDS, -): Filter = Filter(kinds = missingDeletionKinds(deletionKinds), authors = authors) - -/** - * Whether a local deletion-family [event] may be pushed UP to [relay]. - * - * - **kind 5** — always. A deletion request is owner-scoped (the relay only removes - * the deleting author's own events), so propagating it can never delete a third - * party's data; the worst case is a no-op the relay ignores. - * - **kind 62** — only when the request actually targets [relay] (its `relay` tags - * name that URL, or `ALL_RELAYS`). A vanish triggers a pubkey-wide mass delete on - * every relay that ingests it, so we must not fan one out to a relay the author - * never named — we honor the author's declared targets, no broader. - */ -fun shouldPropagateDeletionUp( - event: Event, - relay: NormalizedRelayUrl, -): Boolean = - when (event) { - is RequestToVanishEvent -> event.shouldVanishFrom(relay) - else -> true - } - -/** - * Propagates deletion-family events (kinds 5 & 62) between the local set and [relay], - * **independent of a content sync's [contentFilter]** and **always bidirectional**: - * - * - **down** — deletions the relay has and we lack are handed to [download]; feeding - * them into the local store lets NIP-09/62 remove the targets locally too (and its - * reject-trigger keeps them from being re-added by a later content sync). - * - **up** — deletions we have and the relay lacks are handed to [upload]; publishing - * them makes the relay apply the deletion. Kind-62 vanishes are gated by - * [shouldPropagateDeletionUp] so one is only sent to a relay it targets. - * - * A no-op returning `null` when [contentFilter] already covers every [deletionKinds], - * or when [scopeAuthors] is empty (nothing to scope — an unscopeable deletion set must - * not be reconciled, or it would pull the relay's entire deletion history). - * - * **Scope is the caller's responsibility.** [scopeAuthors] bounds the reconcile — it - * MUST be a bounded author set (the content filter's authors, or the authors actually - * held locally). It defaults to `contentFilter.authors`, so an author-less content - * filter yields an EMPTY scope → this returns null rather than reconcile everything. - * - * The caller owns I/O: [download] fetches + ingests the given ids however it fetches - * content (REQ-by-id, verify, store), and [upload] publishes one local event. Both - * suspend the reconcile round that produced them, so the relay is back-pressured. - * - * @param localDeletions the local deletion events (in [deletionKinds]) — both the - * reconcile set and the source the `have` ids resolve against for [upload]. - * @param scopeAuthors the authors to bound the deletion reconcile to. Empty/`null` - * disables the side-channel (see above). - * @param deletionKinds which deletion kinds to propagate (default 5 & 62). Pass `[5]` - * to propagate only precise NIP-09 deletions and skip account-wide NIP-62 vanishes. - */ -suspend fun INostrClient.negentropyPropagateDeletions( - relay: NormalizedRelayUrl, - contentFilter: Filter, - localDeletions: List, - scopeAuthors: List? = contentFilter.authors, - deletionKinds: List = DELETION_PROPAGATION_KINDS, - batchSize: Int = 500, - idleTimeoutMs: Long = 120_000L, - download: suspend (List) -> Unit, - upload: suspend (Event) -> Unit, -): NegentropyReconcileResult? { - if (scopeAuthors.isNullOrEmpty()) return null - if (!contentFilter.excludesDeletionKinds(deletionKinds)) return null - - val byId = localDeletions.associateBy { it.id } - val localEntries = localDeletions.map { IdAndTime(it.createdAt, it.id) } - - return negentropyReconcile( - relay = relay, - filter = contentFilter.deletionSideChannelFilter(authors = scopeAuthors, deletionKinds = deletionKinds), - localEntries = localEntries, - batchSize = batchSize, - idleTimeoutMs = idleTimeoutMs, - onNeedIds = { batch -> download(batch) }, - onHaveIds = { batch -> - for (id in batch) { - val event = byId[id] ?: continue - if (shouldPropagateDeletionUp(event, relay)) upload(event) - } - }, - ) -} From af7c6c11e1386c9ef15818d7228d9d8e3fab6b08 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 02:45:47 +0000 Subject: [PATCH 05/12] feat: send exactly the deletions that cover a relay's need events (id/addr/vanish) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refine the sync deletion rule to what was asked: for the events the relay HAS that we LACK (the reconcile need set), publish only the local deletions that would actually make the relay remove them — and nothing else, not other deletions by the same author. Determining coverage needs the need event's author/address/created_at, which we don't have for an id we lack, so we fetch the need events (raw — no verify, no store) purely for metadata. quartz gains IEventStore.deletionsCovering(events, relay), which maps server-held events to the covering local deletions across all three forms: - NIP-09 id-based: a kind-5 with an `e` tag naming the event id; - NIP-09 address-based: a kind-5 with an `a` tag naming the event's addressable/replaceable coordinate, at/after it (created_at <= deletion); - NIP-62 vanish: a kind-62 by the event's author, targeting this relay, issued after it (created_at < vanish). SyncCommand's need workers now fetch each need batch once (Context.fetchRaw), publish its covering deletions (deduped across workers), and — when --down — store the rest; anything we deleted is rejected by the store's own tombstone. Nothing is pulled down or applied locally, so it cannot over-delete the store. DeletionSyncTest covers each form (with cutoff and wrong-relay negatives) plus an end-to-end reconcile → cover → publish that removes the note on the relay. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../com/vitorpamplona/amethyst/cli/Context.kt | 73 ++++++++ .../amethyst/cli/commands/SyncCommand.kt | 86 +++++----- .../vitorpamplona/geode/DeletionSyncTest.kt | 158 +++++++++++------- .../nip01Core/store/EventStoreDeletionsExt.kt | 90 ++++++++++ 4 files changed, 299 insertions(+), 108 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index dd0bac444e..928dd3a7ae 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -483,6 +483,79 @@ class Context( return collected } + /** + * Like [drain] but does NOT verify or store — collects the raw events until every + * relay EOSEs or the timeout elapses and returns them. Used when the caller needs + * an event's metadata to make a decision (e.g. which local deletion covers it) + * rather than to keep it. Verification/storage, if wanted, is the caller's job. + */ + suspend fun fetchRaw( + filters: Map>, + timeoutMs: Long = 8_000, + ): List { + if (filters.isEmpty()) return emptyList() + val eventChannel = Channel(UNLIMITED) + val doneChannel = Channel(UNLIMITED) + val remaining = filters.keys.toMutableSet() + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + eventChannel.trySend(event) + } + + override fun onEose( + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onClosed( + message: String, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + + override fun onCannotConnect( + relay: NormalizedRelayUrl, + message: String, + forFilters: List?, + ) { + doneChannel.trySend(relay) + } + } + val collected = mutableListOf() + try { + client.subscribe(subId, filters, listener) + withTimeoutOrNull(timeoutMs) { + while (remaining.isNotEmpty()) { + select { + eventChannel.onReceive { collected.add(it) } + doneChannel.onReceive { r -> remaining.remove(r) } + } + } + while (true) { + val r = eventChannel.tryReceive() + if (!r.isSuccess) break + collected.add(r.getOrThrow()) + } + } + } finally { + client.unsubscribe(subId) + eventChannel.close() + doneChannel.close() + } + return collected + } + /** * Like [drain], but paginates every relay to completion via * [fetchAllPagesFromPool] instead of stopping at the first EOSE — so a query diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index e4da7e9c99..c1e8294fc0 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -31,11 +31,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyRec import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.store.IdAndTime -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** @@ -56,12 +57,14 @@ import java.util.concurrent.atomic.AtomicInteger * `fetch`/`subscribe`; an empty filter reconciles the whole store. * * Deletion propagation is deliberately narrow (on by default; disable with - * `--no-sync-deletions`): for each id the relay HAS that we LACK — the reconcile's - * need set — if we hold a **kind-5** deletion that targets it, that deletion is - * published up, so a note we deleted is deleted on the relay too instead of being - * re-downloaded. That is the whole feature: only the need ids, only kind-5, up - * only. Nothing is pulled down or applied locally, so it can never over-delete this - * store, and it needs no author scoping (the need set already bounds it). + * `--no-sync-deletions`): for the events the relay HAS that we LACK — the reconcile's + * need set — we publish up the local deletions that would make the relay remove them, + * and only those. That covers a NIP-09 kind-5 targeting the event by id (`e` tag) or + * by address (`a` tag, cutoff-checked), and a NIP-62 kind-62 vanish for the event's + * author that targets this relay. The need events are fetched only for their metadata + * (author/address/created_at); nothing is pulled down or applied locally, so it can + * never over-delete this store, and the need set already bounds it (no author scoping). + * See [com.vitorpamplona.quartz.nip01Core.store.deletionsCovering]. * * Both directions are pipelined with the reconcile: need-id batches feed * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single @@ -103,11 +106,14 @@ object SyncCommand { val up = args.bool("up") val down = args.bool("down") || !up // Deletion propagation (on by default; --no-sync-deletions disables). Scope is - // exactly: for the ids the relay HAS that we LACK (the reconcile's need set), if - // we hold a kind-5 deletion targeting one of them, publish that deletion up so - // the relay deletes it too — instead of re-downloading a note we deleted. That - // is the whole feature: only these ids, only kind-5, up only. Nothing is pulled - // down or applied locally, so it can never over-delete this store. + // exactly: for the events the relay HAS that we LACK (the reconcile's need set), + // publish the local deletions that would make the relay remove them — an id- or + // address-based kind-5, or a kind-62 vanish that targets this relay. Only those + // deletions, nothing else (not other deletions by the same author). We fetch the + // need events (not to keep — [Context.fetchRaw] neither verifies nor stores) only + // to learn their author/address/created_at so [deletionsCovering] can tell which + // of our deletions actually apply. Nothing is pulled down or applied locally, so + // this can never over-delete the local store. val syncDeletions = !args.bool("no-sync-deletions") val filter = RawEventSupport.buildFilter(args) @@ -120,26 +126,40 @@ object SyncCommand { val downloaded = AtomicInteger(0) val uploaded = AtomicInteger(0) val deletionsSent = AtomicInteger(0) + // Deduplicate published deletions across the concurrent need workers: one + // deletion often covers several need events. + val sentDeletions = ConcurrentHashMap.newKeySet() val result = try { coroutineScope { // needIds = relay has, we lack; haveIds = we have, relay lacks. - // Bounded so a slow download back-pressures the reconcile - // rounds instead of piling ids up in memory. + // Bounded so a slow worker back-pressures the reconcile rounds + // instead of piling ids up in memory. val needBatches = Channel>(DOWNLOAD_WORKERS * 2) // Unbounded is fine here: have-ids reference events we already // hold locally, so memory is bounded by the local set. val haveBatches = Channel>(Channel.UNLIMITED) - // need-ids routed to the deletion sender (bounded → back-pressure). - val delBatches = Channel>(DOWNLOAD_WORKERS * 2) - val downloaders = + val needWorkers = List(DOWNLOAD_WORKERS) { launch { for (batch in needBatches) { - val got = ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs) - downloaded.addAndGet(got.size) + // Fetch the need events once (raw — no verify/store). + val events = ctx.fetchRaw(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs) + // Push up the deletions that would remove them from the relay. + if (syncDeletions) { + for (del in ctx.store.deletionsCovering(events, relay)) { + if (sentDeletions.add(del.id) && ctx.publish(del, setOf(relay)).values.any { it }) { + deletionsSent.incrementAndGet() + } + } + } + // Download the rest into the local store; anything we + // deleted is rejected by the store's own tombstone. + if (down) { + for (event in events) if (ctx.verifyAndStore(event)) downloaded.incrementAndGet() + } } } } @@ -152,23 +172,6 @@ object SyncCommand { } } } - // For each id the relay has that we lack, publish any local kind-5 - // deletion that targets it (queried by its `e` tag). A note we - // deleted then gets deleted on the relay too, instead of being - // re-downloaded. Most need-ids have no such deletion, so the query - // usually returns empty and nothing is sent. - val deletionSender = - launch { - for (batch in delBatches) { - val mine = - ctx.store.query( - Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to batch)), - ) - for (del in mine) { - if (ctx.publish(del, setOf(relay)).values.any { it }) deletionsSent.incrementAndGet() - } - } - } val reconcile = try { @@ -180,20 +183,17 @@ object SyncCommand { idleTimeoutMs = timeoutMs, reconcileConcurrency = RECONCILE_CONCURRENCY, onHaveIds = if (up) { batch -> haveBatches.send(batch) } else null, - onNeedIds = { batch -> - if (down) needBatches.send(batch) - if (syncDeletions) delBatches.send(batch) - }, + // Fetch need events when we either download them or need + // their metadata to decide which deletions to send. + onNeedIds = { batch -> if (down || syncDeletions) needBatches.send(batch) }, ) } finally { needBatches.close() haveBatches.close() - delBatches.close() } - downloaders.joinAll() + needWorkers.joinAll() uploader.join() - deletionSender.join() reconcile } } catch (e: NegentropySyncException) { diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt index 891a4426a9..bd392fcfff 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -27,103 +27,131 @@ import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync import com.vitorpamplona.quartz.nip01Core.store.IdAndTime +import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue /** - * The `amy sync` deletion rule, end-to-end: for the ids the relay HAS that we LACK - * (the negentropy need set), if we hold a kind-5 deletion targeting one of them, - * publish that deletion up so the relay deletes it too — instead of re-downloading a - * note we deleted. Only these ids, only kind-5, up only; nothing is pulled down or - * applied locally, so the personal store can never be over-deleted. - * - * This exercises the exact wiring `SyncCommand` uses (reconcile → look up the local - * kind-5 by its `e` tag for the need ids → publish), which the CLI itself has no test - * harness for. + * The `amy sync` deletion rule: for the events the relay HAS that we LACK (the + * negentropy need set), publish the local deletions that would make the relay remove + * them — and only those. [deletionsCovering] is the core: it maps a set of server-held + * events to the local deletions that cover them, across id-based (NIP-09 `e`), + * address-based (NIP-09 `a`, cutoff-checked) and NIP-62 vanish (relay-targeted, cutoff). */ class DeletionSyncTest : RelayClientTest() { private val signer = NostrSignerSync(KeyPair()) + private val here: NormalizedRelayUrl get() = defaultRelayUrl + private val elsewhere = RelayUrlNormalizer.normalize("wss://elsewhere.example/") + + private val store = EventStore(null) + + @AfterTest fun closeStore() = store.close() private fun note(text: String): Event = signer.sign(TextNoteEvent.build(text)) - private fun deletionOf(target: Event): Event = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) - - /** The SyncCommand step under test: publish local kind-5 deletions targeting [needIds]. */ - private suspend fun sendDeletionsFor( - local: com.vitorpamplona.geode.RelayEngine, - needIds: List, - ): Int { - var sent = 0 - val mine = local.store.query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to needIds))) - for (del in mine) { - defaultRelay.publish(del) - sent++ - } - return sent - } + // ---- deletionsCovering: the three coverage forms -------------------------- @Test - fun sendsDeletionForANeedIdWeDeleted() = + fun idBasedDeletionCoversByETag() = runBlocking { - val note = note("delete me") - val deletion = deletionOf(note) + val target = note("delete me") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + store.insert(deletion) - // Relay has the note (no deletion). - defaultRelay.preload(listOf(note)) - assertEquals(1, defaultRelay.store.query(Filter(ids = listOf(note.id))).size) + assertEquals(listOf(deletion.id), store.deletionsCovering(listOf(target), here).map { it.id }) + // A different note the deletion doesn't name is not covered. + assertTrue(store.deletionsCovering(listOf(note("unrelated")), here).isEmpty()) + } - // Local already applied the deletion → it holds only the kind-5, so the - // note is a "need" (relay has it, we lack it). + @Test + fun addressBasedDeletionCoversByATagWithCutoff() = + runBlocking { + val contacts = ContactListEvent.createFromScratch(emptyList(), null, signer) + // Address-only deletion (no `e` tag) → only the `a`-tag path can match it. + val delAddr = signer.sign(DeletionEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt + 1)) + store.insert(delAddr) + + assertEquals( + listOf(delAddr.id), + store.deletionsCovering(listOf(contacts), here).map { it.id }, + "a replaceable event is covered by an address deletion at/after it", + ) + + // NIP-09 cutoff: a deletion OLDER than the event does not delete it. + val stale = EventStore(null) + stale.insert(signer.sign(DeletionEvent.buildAddressOnly(listOf(contacts), createdAt = contacts.createdAt - 1))) + assertTrue(stale.deletionsCovering(listOf(contacts), here).isEmpty(), "an older address deletion does not cover") + stale.close() + } + + @Test + fun vanishCoversAuthorsEventsWhenTargetedAndNewer() = + runBlocking { + val old = note("before the vanish") + val vanishHere = signer.sign(RequestToVanishEvent.build(here, createdAt = old.createdAt + 1)) + store.insert(vanishHere) + + assertEquals( + listOf(vanishHere.id), + store.deletionsCovering(listOf(old), here).map { it.id }, + "a relay-targeted vanish issued after the event covers it", + ) + + // Not targeting this relay → not sent here. + val otherStore = EventStore(null) + otherStore.insert(signer.sign(RequestToVanishEvent.build(elsewhere, createdAt = old.createdAt + 1))) + assertTrue(otherStore.deletionsCovering(listOf(old), here).isEmpty(), "a vanish for another relay is not sent") + + // A newer event (created after the vanish) is NOT deleted by it. + val newer = signer.sign(TextNoteEvent.build("after", createdAt = vanishHere.createdAt + 10)) + assertTrue(store.deletionsCovering(listOf(newer), here).isEmpty(), "the vanish does not cover a later event") + otherStore.close() + } + + // ---- end-to-end through the relay ---------------------------------------- + + @Test + fun sendsCoveringDeletionSoRelayRemovesTheNote() = + runBlocking { + val target = note("delete me e2e") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + + // Relay holds the note; we already deleted it locally (hold only the kind-5). + defaultRelay.preload(listOf(target)) val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local/")) - local.preload(listOf(note, deletion)) - assertTrue(local.store.query(Filter(ids = listOf(note.id))).isEmpty(), "local deleted the note") + local.preload(listOf(target, deletion)) + assertTrue(local.store.query(Filter(ids = listOf(target.id))).isEmpty(), "local deleted the note") - val localKind1 = local.store.query(Filter(kinds = listOf(1))).map { IdAndTime(it.createdAt, it.id) } + // Reconcile → the note is a need id. (No local kind-1 remains.) val diff = withTimeout(20_000) { - client.negentropyReconcileIds(relay = defaultRelayUrl, filter = Filter(kinds = listOf(1)), localEntries = localKind1) + client.negentropyReconcileIds(relay = defaultRelayUrl, filter = Filter(kinds = listOf(1)), localEntries = emptyList()) } - assertEquals(setOf(note.id), diff.needIds.toSet(), "the deleted note is the only need id") + assertEquals(setOf(target.id), diff.needIds.toSet()) - val sent = sendDeletionsFor(local, diff.needIds) + // What SyncCommand does: fetch the need events, ask the local store which of + // our deletions cover them, publish those. + val serverEvents = defaultRelay.store.query(Filter(ids = diff.needIds)) + val covering = local.store.deletionsCovering(serverEvents, defaultRelayUrl) + assertEquals(listOf(deletion.id), covering.map { it.id }) + covering.forEach { defaultRelay.publish(it) } - assertEquals(1, sent, "the deletion targeting the need id was sent") assertTrue( - defaultRelay.store.query(Filter(ids = listOf(note.id))).isEmpty(), + defaultRelay.store.query(Filter(ids = listOf(target.id))).isEmpty(), "relay applied the pushed deletion and removed the note", ) } - - @Test - fun sendsNothingForANeedIdWeNeverHad() = - runBlocking { - // Relay has a note we simply never had and never deleted — a plain download, - // no deletion to send. - val other = note("just never had this") - defaultRelay.preload(listOf(other)) - - val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local2/")) - // Local holds an unrelated deletion (targets a different note) — must NOT be - // sent for `other`. - local.preload(listOf(deletionOf(note("unrelated")))) - - val diff = - withTimeout(20_000) { - client.negentropyReconcileIds(relay = defaultRelayUrl, filter = Filter(kinds = listOf(1)), localEntries = emptyList()) - } - assertTrue(other.id in diff.needIds, "the note is a need id") - - val sent = sendDeletionsFor(local, diff.needIds) - - assertEquals(0, sent, "no deletion targets the need id, so nothing is sent") - assertEquals(1, defaultRelay.store.query(Filter(ids = listOf(other.id))).size, "the note is untouched on the relay") - } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt new file mode 100644 index 0000000000..2e07ee30bf --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt @@ -0,0 +1,90 @@ +/* + * 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.quartz.nip01Core.store + +import com.vitorpamplona.quartz.nip01Core.core.Address +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.isAddressable +import com.vitorpamplona.quartz.nip01Core.core.isReplaceable +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip62RequestToVanish.RequestToVanishEvent + +/** The addressable/replaceable coordinate of [event] as a NIP-01 `a`-tag value. */ +private fun addressValue(event: Event): String { + val dTag = if (event.kind.isAddressable()) event.tags.firstOrNull { it.size > 1 && it[0] == "d" }?.get(1) ?: "" else "" + return Address.assemble(event.kind, event.pubKey, dTag) +} + +/** + * The local deletion events that would make [relay] remove one of [serverEvents] — the + * events the relay HAS that we LACK. Used by sync to push *only* the deletions that + * actually apply to what the relay holds, and nothing else (not other deletions by the + * same author). Covers every way a stored deletion can reach an event: + * + * - **NIP-09, id-based** — a kind-5 with an `e` tag naming a server event's id. + * - **NIP-09, address-based** — a kind-5 with an `a` tag naming a server event's + * addressable/replaceable coordinate, at or after that event's `created_at` + * (NIP-09 only deletes `created_at <= deletion.created_at`). + * - **NIP-62 vanish** — a kind-62 by a server event's author, targeting [relay] (its + * `relay` tags name the URL or `ALL_RELAYS`), issued after that event (a vanish + * deletes `created_at < vanish.created_at`). + * + * Deduped by event id; a single deletion covering several server events is returned once. + */ +suspend fun IEventStore.deletionsCovering( + serverEvents: List, + relay: NormalizedRelayUrl, +): List { + if (serverEvents.isEmpty()) return emptyList() + val covering = LinkedHashMap() + + // 1. id-based NIP-09: a kind-5 `e`-tagging a server id. + query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to serverEvents.map { it.id }))) + .forEach { covering[it.id] = it } + + // 2. address-based NIP-09: a kind-5 `a`-tagging a server event's coordinate, cutoff-checked. + val byAddress = serverEvents.filter { it.kind.isAddressable() || it.kind.isReplaceable() }.groupBy(::addressValue) + if (byAddress.isNotEmpty()) { + query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("a" to byAddress.keys.toList()))) + .forEach { del -> + if (del !is DeletionEvent) return@forEach + for (addr in del.deleteAddresses()) { + val hit = byAddress[addr.toValue()] ?: continue + if (hit.any { it.createdAt <= del.createdAt }) { + covering[del.id] = del + break + } + } + } + } + + // 3. NIP-62 vanish: a kind-62 by a server author, targeting this relay, issued after the event. + query(Filter(kinds = listOf(RequestToVanishEvent.KIND), authors = serverEvents.mapTo(HashSet()) { it.pubKey }.toList())) + .forEach { vanish -> + if (vanish !is RequestToVanishEvent || !vanish.shouldVanishFrom(relay)) return@forEach + if (serverEvents.any { it.pubKey == vanish.pubKey && it.createdAt < vanish.createdAt }) covering[vanish.id] = vanish + } + + return covering.values.toList() +} From 0af65b4296921777cb3efdb711863877173d84ec Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 12:59:47 +0000 Subject: [PATCH 06/12] refactor: use quartz INostrClient.fetchAll instead of a bespoke Context.fetchRaw MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fetchAll already does exactly what the need-metadata fetch needs — subscribe, collect (deduped by id), return on EOSE/timeout, no verify, no store — so drop the duplicated Context.fetchRaw and call the existing extension. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../com/vitorpamplona/amethyst/cli/Context.kt | 73 ------------------- .../amethyst/cli/commands/SyncCommand.kt | 8 +- 2 files changed, 5 insertions(+), 76 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt index 928dd3a7ae..dd0bac444e 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Context.kt @@ -483,79 +483,6 @@ class Context( return collected } - /** - * Like [drain] but does NOT verify or store — collects the raw events until every - * relay EOSEs or the timeout elapses and returns them. Used when the caller needs - * an event's metadata to make a decision (e.g. which local deletion covers it) - * rather than to keep it. Verification/storage, if wanted, is the caller's job. - */ - suspend fun fetchRaw( - filters: Map>, - timeoutMs: Long = 8_000, - ): List { - if (filters.isEmpty()) return emptyList() - val eventChannel = Channel(UNLIMITED) - val doneChannel = Channel(UNLIMITED) - val remaining = filters.keys.toMutableSet() - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - eventChannel.trySend(event) - } - - override fun onEose( - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - doneChannel.trySend(relay) - } - - override fun onClosed( - message: String, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - doneChannel.trySend(relay) - } - - override fun onCannotConnect( - relay: NormalizedRelayUrl, - message: String, - forFilters: List?, - ) { - doneChannel.trySend(relay) - } - } - val collected = mutableListOf() - try { - client.subscribe(subId, filters, listener) - withTimeoutOrNull(timeoutMs) { - while (remaining.isNotEmpty()) { - select { - eventChannel.onReceive { collected.add(it) } - doneChannel.onReceive { r -> remaining.remove(r) } - } - } - while (true) { - val r = eventChannel.tryReceive() - if (!r.isSuccess) break - collected.add(r.getOrThrow()) - } - } - } finally { - client.unsubscribe(subId) - eventChannel.close() - doneChannel.close() - } - return collected - } - /** * Like [drain], but paginates every relay to completion via * [fetchAllPagesFromPool] instead of stopping at the first EOSE — so a query diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index c1e8294fc0..333c8a2677 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -27,6 +27,7 @@ import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -110,7 +111,7 @@ object SyncCommand { // publish the local deletions that would make the relay remove them — an id- or // address-based kind-5, or a kind-62 vanish that targets this relay. Only those // deletions, nothing else (not other deletions by the same author). We fetch the - // need events (not to keep — [Context.fetchRaw] neither verifies nor stores) only + // need events (not to keep — `fetchAll` neither verifies nor stores) only // to learn their author/address/created_at so [deletionsCovering] can tell which // of our deletions actually apply. Nothing is pulled down or applied locally, so // this can never over-delete the local store. @@ -145,8 +146,9 @@ object SyncCommand { List(DOWNLOAD_WORKERS) { launch { for (batch in needBatches) { - // Fetch the need events once (raw — no verify/store). - val events = ctx.fetchRaw(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs) + // Fetch the need events once (no verify/store — we only + // need their metadata to decide which deletions apply). + val events = ctx.client.fetchAll(relay, Filter(ids = batch), timeoutMs) // Push up the deletions that would remove them from the relay. if (syncDeletions) { for (del in ctx.store.deletionsCovering(events, relay)) { From c57681b3e93cb2791016694eb10d23edd59788f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 13:25:13 +0000 Subject: [PATCH 07/12] docs: catalog INostrClient relay-client extensions so they're discoverable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The one-shot/high-level relay ops (fetchAll, fetchFirst, fetchAllPages, publishAndConfirm, count, negentropy sync/reconcile, …) are INostrClient extension functions spread across ~8 files with no index, so they don't surface under "usages of NostrClient" or in completion — easy to miss and re-implement (as just happened with a bespoke fetchRaw duplicating fetchAll). - Add accessories/README.md cataloging each public extension with a one-line "use when". - CLAUDE.md (Feature Workflow): point at that package/README before hand-rolling a subscribe/REQ/publish loop. - relay-client skill: add a Related note steering headless/one-shot callers to the accessories instead of Subscribable. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .claude/CLAUDE.md | 11 ++++ .claude/skills/relay-client/SKILL.md | 6 ++ .../relay/client/accessories/README.md | 61 +++++++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 2a019ff766..0d3293a869 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -160,6 +160,17 @@ Summarize the survey in your plan: for each component, note whether it's reused as-is, extracted from `amethyst/` to `commons/`, genuinely new (platform-specific only), or a duplicate of an existing pattern to avoid. +**Relay client ops already exist — don't hand-roll subscribe/REQ/publish loops.** +One-shot and high-level relay operations (fetch a set, fetch one, page past the +relay cap, publish-and-confirm, NIP-45 count, NIP-77 sync/reconcile) are +`INostrClient` **extension functions** in +`quartz/…/nip01Core/relay/client/accessories/` (+ `…/reqs/` for the flow/subscribe +helpers). Because they're extensions, they don't surface under "usages of +`NostrClient`" or in completion — grep that package (or read its `README.md`, which +catalogs them) before writing a new subscription/collect loop. Reuse `fetchAll`, +`fetchFirst`, `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`, +etc. instead of re-implementing them. + **Share vs keep platform-native:** - **Share** → `quartz/commonMain/` (business logic, data models, protocol) and diff --git a/.claude/skills/relay-client/SKILL.md b/.claude/skills/relay-client/SKILL.md index 0bdea82309..c1019cde8f 100644 --- a/.claude/skills/relay-client/SKILL.md +++ b/.claude/skills/relay-client/SKILL.md @@ -123,6 +123,12 @@ Each subscription tracks "End of Stored Events" per relay. The eose manager in ` ## Related +- **Headless / one-shot client ops** (CLI, geode, tests, non-compose code): don't go + through `Subscribable` — use the `INostrClient` extension functions in + `quartz/…/nip01Core/relay/client/accessories/` (`fetchAll`, `fetchFirst`, + `fetchAllPages`, `publishAndConfirm`, `count`, `negentropyReconcile`/`negentropySync`, + …). They're extensions, so they don't show up under "usages of `NostrClient`" — see + that package's `README.md` for the catalog before writing a raw subscribe/collect loop. - `nostr-expert/references/tag-patterns.md` — how tags inform what a filter needs to look for. - `kotlin-coroutines/references/relay-patterns.md` — relay pool internals (sibling layer beneath assemblers). - `feed-patterns` skill — feeds compose several Subscribables (content + metadata + reactions). diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md new file mode 100644 index 0000000000..dbf9fbcf63 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -0,0 +1,61 @@ +# `INostrClient` accessories + +One-shot / high-level relay operations, written as **extension functions** on +`INostrClient`. They live here (and in `../reqs/`) rather than on the client class, +so they don't show up under "usages of `NostrClient`" or in method completion — you +only find them by knowing this package exists. + +**Before writing a new subscribe / REQ / publish loop, look here first.** Most of what +a caller needs (fetch a set, fetch one, page past the relay cap, publish-and-confirm, +count, negentropy sync/reconcile) already exists. + +Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.` (or +`...client.reqs.` for the flow/subscribe helpers). + +## One-shot reads (subscribe → collect → return) + +| Function | File | Use when | +| --- | --- | --- | +| `fetchAll(relay, filter, timeoutMs)` | `NostrClientFetchAllExt` | Get every event matching a filter in one REQ, deduped by id, until EOSE or timeout. **No verify, no store** — just the events. | +| `fetchFirst(relay, filter, timeoutMs)` | `NostrClientFetchFirstExt` | Get the first matching event and stop (returns `null` on none/timeout). | +| `fetchAllPages(relay, filters, timeoutMs)` | `NostrClientFetchAllPagesExt` | Fully retrieve a result set larger than the relay's per-REQ cap (strfry `limit`, ~500) by walking a `created_at` cursor. Bound it with the filter's `limit`. | +| `fetchAllPagesFromPool(filters, ...)` | `NostrClientFetchAllPagesPoolExt` | Same paging, across several relays at once, deduped across them. | + +## Streaming (`Flow`) + +| Function | File | Use when | +| --- | --- | --- | +| `fetchAsFlow(relay, filter)` | `../reqs/NostrClientFetchAsFlowExt` | Emit the accumulating list on each arrival; completes on EOSE. One-shot query as a flow. | +| `subscribeAsFlow(relay, filter)` | `../reqs/NostrClientSubscribeAsFlowExt` | Live subscription as a flow (stays open past EOSE; re-sends the REQ on reconnect). | +| `subscribe(subId, filters, listener)` | `../reqs/StaticSubscription`, `DynamicSubscription` | Raw live subscription with a `SubscriptionListener`. The lowest-level primitive the above build on. | + +## Publish + +| Function | File | Use when | +| --- | --- | --- | +| `publishAndConfirm(event, relays, timeout)` | `NostrClientPublishExt` | Send an EVENT and wait for `OK`; returns whether any relay accepted it. | +| `publishAndConfirmDetailed(event, relays, timeout)` | `NostrClientPublishExt` | Same, but returns the per-relay accepted/rejected map. | + +## Count (NIP-45) + +| Function | File | Use when | +| --- | --- | --- | +| `count(relay, filter, timeoutMs)` | `NostrClientCountExt` | NIP-45 `COUNT` against one relay (`null` on timeout / no support). | +| `countMerged(relays, filter, ...)` | `NostrClientCountExt` | Merged count across relays. | + +## Negentropy (NIP-77) + +| Function | File | Use when | +| --- | --- | --- | +| `negentropySync(relay, filter, ...)` | `NostrClientNegentropySyncExt` | Download everything a relay holds for a filter, diffing against `localEntries` and by-id downloading only the diff. Throws `NegentropySyncException` if the relay can't reconcile (no fallback). | +| `negentropySyncOrFetch(relay, filter, ...)` | `NostrClientNegentropySyncExt` | Same, but transparently falls back to `fetchAllPages` when the relay can't reconcile. The "just get the events" combinator. | +| `negentropySyncEvents` / `negentropySyncOrFetchEvents` | `NostrClientNegentropySyncEventsExt` | The two above as an O(1)-memory `Flow`. | +| `negentropyReconcile(relay, filter, localEntries, onNeedIds, onHaveIds)` | `NostrClientNegentropySyncExt` | **Pure diff, no I/O** — streams the two directions (`need` = relay has & we lack; `have` = we have & relay lacks) to callbacks. Compose your own download/upload on top. | +| `negentropyReconcileIds(relay, filter, localEntries)` | `NostrClientNegentropySyncExt` | Same diff, materialized into `needIds` / `haveIds` lists (small sets only). | + +`fetchByIds`, `reconcileStreaming`, `syncPipeline` in `NostrClientNegentropySyncExt` +are `internal` implementation details — not part of the public surface. + +--- + +_Keep this table in sync when you add a public `INostrClient` extension here._ From 677c0ee2074f274af5a5ad40b8a183e6206b66c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 14:25:37 +0000 Subject: [PATCH 08/12] refactor: deletion sync as a post-settle residual pass (both directions, O(residual)) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the per-need-event fetch (which pulled the whole need set just to read metadata — an O(db) regression on large syncs) with a second reconcile pass over the residual, per the "settle, then diff, then explain what didn't converge" idea. Pass 1 is the plain content sync again (drain needs, publish haves) — zero deletion overhead. Pass 2+ re-reconciles; the leftover diff is exactly the deletion mismatches, and only that (tiny) set is fetched: - residual need (relay has it, we still lack it after --down) = we deleted it → publish our covering deletion up so the relay drops it; - residual have (we have it, relay still lacks it after --up) = the relay deleted it → pull the relay's covering kind-5 down and apply locally (vanish is NOT auto-applied on pull — account-wide blast radius). Loops until a round resolves nothing (converges + self-verifies). So `amy sync` makes the relay honor our deletions; `--up` makes us honor the relay's; `--up --down` converges both ways. Cost is one cheap reconcile + the residual regardless of database size — the large-DB bottleneck is gone by construction, not by heuristics. quartz: deletionsCovering is now source-agnostic (takes a query lambda) so the same coverage rule runs against the local store (up) or the relay (down); the IEventStore overload is the local convenience. Tests: DeletionSyncTest gains the down-direction end-to-end (relay deleted → local removes) alongside the up-direction and the per-form unit cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../amethyst/cli/commands/SyncCommand.kt | 169 ++++++++++++------ .../vitorpamplona/geode/DeletionSyncTest.kt | 42 +++++ .../nip01Core/store/EventStoreDeletionsExt.kt | 38 ++-- 3 files changed, 180 insertions(+), 69 deletions(-) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 333c8a2677..540b5c9894 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -29,15 +29,16 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.store.IdAndTime import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch -import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.atomic.AtomicInteger /** @@ -57,25 +58,29 @@ import java.util.concurrent.atomic.AtomicInteger * Pass both for a full bidirectional sync. The filter flags are the same as * `fetch`/`subscribe`; an empty filter reconciles the whole store. * - * Deletion propagation is deliberately narrow (on by default; disable with - * `--no-sync-deletions`): for the events the relay HAS that we LACK — the reconcile's - * need set — we publish up the local deletions that would make the relay remove them, - * and only those. That covers a NIP-09 kind-5 targeting the event by id (`e` tag) or - * by address (`a` tag, cutoff-checked), and a NIP-62 kind-62 vanish for the event's - * author that targets this relay. The need events are fetched only for their metadata - * (author/address/created_at); nothing is pulled down or applied locally, so it can - * never over-delete this store, and the need set already bounds it (no author scoping). - * See [com.vitorpamplona.quartz.nip01Core.store.deletionsCovering]. + * Deletion propagation (on by default; disable with `--no-sync-deletions`) is a + * **second pass over the residual**, not per-event work in the content pass — so it + * costs the same whether the database is tiny or huge. After the content settle, a + * re-reconcile's leftover diff is (barring races) exactly the events a deletion kept + * from converging: * - * Both directions are pipelined with the reconcile: need-id batches feed - * [DOWNLOAD_WORKERS] concurrent by-id REQ drains and have-ids feed a single - * uploader, so downloads and uploads overlap the remaining reconcile rounds - * instead of waiting for the full diff. Every downloaded event funnels - * through `Context.drain`'s verify-and-store path, unchanged. + * - a residual **need** (relay has it, we still lack it after `--down` tried to + * download) = we deleted it → publish OUR covering deletion up so the relay drops it; + * - a residual **have** (we have it, relay still lacks it after `--up` tried to upload) + * = the relay deleted it → pull the relay's covering kind-5 down and apply it locally. * - * Thin assembly only: the windowing, streaming, and back-pressure live in - * quartz (`negentropyReconcile`); this file only routes ids to - * `Context.drain` / `Context.publish`. + * Coverage is any way a deletion reaches an event ([deletionsCovering]): a NIP-09 kind-5 + * by id (`e`) or address (`a`, cutoff-checked), or a NIP-62 vanish targeting this relay + * (up direction only — a pulled vanish is not auto-applied, its blast radius being the + * whole account). The residual is small (only real deletion mismatches), so only it is + * fetched — never the whole need set. The loop repeats until a round resolves nothing. + * So `amy sync` (default `--down`) makes the relay honor your deletions; `--up` makes + * your store honor the relay's; `--up --down` converges both ways. + * + * Content is pipelined with the reconcile: need-id batches feed [DOWNLOAD_WORKERS] + * concurrent by-id REQ drains and have-ids feed a single uploader. Thin assembly only: + * the windowing, streaming, and back-pressure live in quartz (`negentropyReconcile`); + * this file only routes ids to `Context.drain` / `Context.publish`. */ object SyncCommand { private const val ID_CHUNK = 500 @@ -91,6 +96,15 @@ object SyncCommand { /** Overlapped `created_at`-window reconciles after an over-cap split. */ private const val RECONCILE_CONCURRENCY = 2 + /** + * Cap on deletion-settle rounds. Each round resolves the residual it can and + * re-reconciles; a healthy sync converges in 1–2 (round N sends/applies, round + * N+1 confirms empty). The cap only bounds pathological non-convergence (e.g. a + * relay that refuses a deletion), which the "resolved nothing → stop" check + * normally catches first. + */ + private const val MAX_DELETION_ROUNDS = 4 + suspend fun run( dataDir: DataDir, rest: Array, @@ -106,15 +120,6 @@ object SyncCommand { // Default direction is download; --up adds upload. val up = args.bool("up") val down = args.bool("down") || !up - // Deletion propagation (on by default; --no-sync-deletions disables). Scope is - // exactly: for the events the relay HAS that we LACK (the reconcile's need set), - // publish the local deletions that would make the relay remove them — an id- or - // address-based kind-5, or a kind-62 vanish that targets this relay. Only those - // deletions, nothing else (not other deletions by the same author). We fetch the - // need events (not to keep — `fetchAll` neither verifies nor stores) only - // to learn their author/address/created_at so [deletionsCovering] can tell which - // of our deletions actually apply. Nothing is pulled down or applied locally, so - // this can never over-delete the local store. val syncDeletions = !args.bool("no-sync-deletions") val filter = RawEventSupport.buildFilter(args) @@ -126,42 +131,23 @@ object SyncCommand { val downloaded = AtomicInteger(0) val uploaded = AtomicInteger(0) - val deletionsSent = AtomicInteger(0) - // Deduplicate published deletions across the concurrent need workers: one - // deletion often covers several need events. - val sentDeletions = ConcurrentHashMap.newKeySet() + // ── Pass 1: content settle — download needs, upload haves. No deletion + // logic, so a plain sync costs exactly what it always did. val result = try { coroutineScope { // needIds = relay has, we lack; haveIds = we have, relay lacks. - // Bounded so a slow worker back-pressures the reconcile rounds - // instead of piling ids up in memory. val needBatches = Channel>(DOWNLOAD_WORKERS * 2) - // Unbounded is fine here: have-ids reference events we already - // hold locally, so memory is bounded by the local set. val haveBatches = Channel>(Channel.UNLIMITED) - val needWorkers = + val downloaders = List(DOWNLOAD_WORKERS) { launch { for (batch in needBatches) { - // Fetch the need events once (no verify/store — we only - // need their metadata to decide which deletions apply). - val events = ctx.client.fetchAll(relay, Filter(ids = batch), timeoutMs) - // Push up the deletions that would remove them from the relay. - if (syncDeletions) { - for (del in ctx.store.deletionsCovering(events, relay)) { - if (sentDeletions.add(del.id) && ctx.publish(del, setOf(relay)).values.any { it }) { - deletionsSent.incrementAndGet() - } - } - } - // Download the rest into the local store; anything we - // deleted is rejected by the store's own tombstone. - if (down) { - for (event in events) if (ctx.verifyAndStore(event)) downloaded.incrementAndGet() - } + // drain verifies + stores; anything we deleted is + // rejected by our own tombstone and stays a "need". + downloaded.addAndGet(ctx.drain(mapOf(relay to listOf(Filter(ids = batch))), timeoutMs).size) } } } @@ -185,16 +171,14 @@ object SyncCommand { idleTimeoutMs = timeoutMs, reconcileConcurrency = RECONCILE_CONCURRENCY, onHaveIds = if (up) { batch -> haveBatches.send(batch) } else null, - // Fetch need events when we either download them or need - // their metadata to decide which deletions to send. - onNeedIds = { batch -> if (down || syncDeletions) needBatches.send(batch) }, + onNeedIds = { batch -> if (down) needBatches.send(batch) }, ) } finally { needBatches.close() haveBatches.close() } - needWorkers.joinAll() + downloaders.joinAll() uploader.join() reconcile } @@ -202,6 +186,75 @@ object SyncCommand { return Output.error("sync_error", e.message ?: "negentropy sync failed") } + // ── Pass 2+: deletion settle. After the content pass, a re-reconcile's + // residual is (barring races) exactly the events a deletion kept from moving: + // - a residual NEED (relay has it, we still lack it after trying to download) + // = we deleted it → publish OUR covering deletion up so the relay drops it; + // - a residual HAVE (we have it, relay still lacks it after trying to upload) + // = the relay deleted it → pull the relay's covering kind-5 down and apply. + // The residual is tiny (only real deletion mismatches), so this is cheap no + // matter how large the database is — we only fetch metadata for the residual, + // never the whole need set. Loop until a round resolves nothing (converged) or + // we hit the round cap. Best-effort: a failed reconcile here never fails the + // command — the content sync already succeeded. + var deletionsUp = 0 + var deletionsDown = 0 + var deletionRounds = 0 + if (syncDeletions && (down || up)) { + val sentUp = HashSet() + val appliedDown = HashSet() + try { + while (deletionRounds < MAX_DELETION_ROUNDS) { + deletionRounds++ + val diff = + ctx.client.negentropyReconcileIds( + relay = relay, + filter = filter, + localEntries = ctx.store.snapshotIdsForNegentropy(listOf(filter)), + batchSize = ID_CHUNK, + idleTimeoutMs = timeoutMs, + reconcileConcurrency = RECONCILE_CONCURRENCY, + ) + var resolved = 0 + + // residual needs → send our deletions up (bounded: --down settled + // every need we don't have a deletion for). + if (down) { + for (chunk in diff.needIds.chunked(ID_CHUNK)) { + val events = ctx.client.fetchAll(relay, Filter(ids = chunk), timeoutMs) + for (del in ctx.store.deletionsCovering(events, relay)) { + if (sentUp.add(del.id) && ctx.publish(del, setOf(relay)).values.any { it }) { + deletionsUp++ + resolved++ + } + } + } + } + + // residual haves → apply the relay's deletions locally (bounded: + // --up settled every have the relay didn't delete). Only precise + // kind-5 deletions are pulled down; a kind-62 vanish is NOT + // auto-applied (its blast radius is the whole account). + if (up) { + for (chunk in diff.haveIds.chunked(ID_CHUNK)) { + val ourEvents = ctx.store.query(Filter(ids = chunk)) + val relayDeletions = deletionsCovering(ourEvents, relay) { f -> ctx.client.fetchAll(relay, f, timeoutMs) } + for (del in relayDeletions.filterIsInstance()) { + if (appliedDown.add(del.id) && ctx.verifyAndStore(del)) { + deletionsDown++ + resolved++ + } + } + } + } + + if (resolved == 0) break + } + } catch (e: NegentropySyncException) { + // content already synced; deletion convergence is best-effort. + } + } + Output.emit( mapOf( "relay" to relay.url, @@ -211,7 +264,9 @@ object SyncCommand { "have" to result.haveCount, "downloaded" to downloaded.get(), "uploaded" to uploaded.get(), - "deletions_sent" to deletionsSent.get(), + "deletions_sent_up" to deletionsUp, + "deletions_applied_down" to deletionsDown, + "deletion_rounds" to deletionRounds, ), ) return 0 diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt index bd392fcfff..90327e529a 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -123,6 +123,7 @@ class DeletionSyncTest : RelayClientTest() { // ---- end-to-end through the relay ---------------------------------------- + // UP direction: we deleted it, the relay still has it → send our deletion up. @Test fun sendsCoveringDeletionSoRelayRemovesTheNote() = runBlocking { @@ -154,4 +155,45 @@ class DeletionSyncTest : RelayClientTest() { "relay applied the pushed deletion and removed the note", ) } + + // DOWN direction: the relay deleted it, we still have it → pull the relay's deletion + // down and apply it locally (the residual-have resolution). + @Test + fun appliesRelaysDeletionSoLocalRemovesTheNote() = + runBlocking { + val target = note("delete me down") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + + // Relay already applied the deletion → holds only the kind-5. + defaultRelay.preload(listOf(target, deletion)) + assertTrue(defaultRelay.store.query(Filter(ids = listOf(target.id))).isEmpty(), "relay deleted the note") + + // Local still holds the note (never saw the deletion). + val local = hub.getOrCreate(RelayUrlNormalizer.normalize("ws://local-down/")) + local.preload(listOf(target)) + assertEquals(1, local.store.query(Filter(ids = listOf(target.id))).size) + + // Reconcile → the note is a HAVE (we have it, the relay lacks it). + val diff = + withTimeout(20_000) { + client.negentropyReconcileIds( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + localEntries = listOf(IdAndTime(target.createdAt, target.id)), + ) + } + assertEquals(setOf(target.id), diff.haveIds.toSet()) + + // What SyncCommand does for the down direction: take our have events, ask the + // RELAY which of ITS deletions cover them, and apply those locally. + val ourEvents = local.store.query(Filter(ids = diff.haveIds)) + val relayDeletions = deletionsCovering(ourEvents, defaultRelayUrl) { f -> defaultRelay.store.query(f) } + assertEquals(listOf(deletion.id), relayDeletions.map { it.id }) + relayDeletions.filterIsInstance().forEach { local.store.insert(it) } + + assertTrue( + local.store.query(Filter(ids = listOf(target.id))).isEmpty(), + "local applied the pulled deletion and removed the note", + ) + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt index 2e07ee30bf..0c5a8203dc 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/store/EventStoreDeletionsExt.kt @@ -50,23 +50,31 @@ private fun addressValue(event: Event): String { * `relay` tags name the URL or `ALL_RELAYS`), issued after that event (a vanish * deletes `created_at < vanish.created_at`). * - * Deduped by event id; a single deletion covering several server events is returned once. + * Deduped by event id; a single deletion covering several events is returned once. + * + * [query] is where the deletions are looked up — it is source-agnostic on purpose, so + * the same coverage rule runs in both sync directions: + * - **up** (send our deletions): `events` are the relay's, `query` is the local store — + * which of OUR deletions would delete what the relay still holds. + * - **down** (apply the relay's deletions): `events` are ours, `query` fetches from the + * relay — which of the RELAY'S deletions would delete what we still hold. */ -suspend fun IEventStore.deletionsCovering( - serverEvents: List, +suspend fun deletionsCovering( + events: List, relay: NormalizedRelayUrl, + query: suspend (Filter) -> List, ): List { - if (serverEvents.isEmpty()) return emptyList() + if (events.isEmpty()) return emptyList() val covering = LinkedHashMap() - // 1. id-based NIP-09: a kind-5 `e`-tagging a server id. - query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to serverEvents.map { it.id }))) + // 1. id-based NIP-09: a kind-5 `e`-tagging an event's id. + query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("e" to events.map { it.id }))) .forEach { covering[it.id] = it } - // 2. address-based NIP-09: a kind-5 `a`-tagging a server event's coordinate, cutoff-checked. - val byAddress = serverEvents.filter { it.kind.isAddressable() || it.kind.isReplaceable() }.groupBy(::addressValue) + // 2. address-based NIP-09: a kind-5 `a`-tagging an event's coordinate, cutoff-checked. + val byAddress = events.filter { it.kind.isAddressable() || it.kind.isReplaceable() }.groupBy(::addressValue) if (byAddress.isNotEmpty()) { - query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("a" to byAddress.keys.toList()))) + query(Filter(kinds = listOf(DeletionEvent.KIND), tags = mapOf("a" to byAddress.keys.toList()))) .forEach { del -> if (del !is DeletionEvent) return@forEach for (addr in del.deleteAddresses()) { @@ -79,12 +87,18 @@ suspend fun IEventStore.deletionsCovering( } } - // 3. NIP-62 vanish: a kind-62 by a server author, targeting this relay, issued after the event. - query(Filter(kinds = listOf(RequestToVanishEvent.KIND), authors = serverEvents.mapTo(HashSet()) { it.pubKey }.toList())) + // 3. NIP-62 vanish: a kind-62 by an event's author, targeting this relay, issued after it. + query(Filter(kinds = listOf(RequestToVanishEvent.KIND), authors = events.mapTo(HashSet()) { it.pubKey }.toList())) .forEach { vanish -> if (vanish !is RequestToVanishEvent || !vanish.shouldVanishFrom(relay)) return@forEach - if (serverEvents.any { it.pubKey == vanish.pubKey && it.createdAt < vanish.createdAt }) covering[vanish.id] = vanish + if (events.any { it.pubKey == vanish.pubKey && it.createdAt < vanish.createdAt }) covering[vanish.id] = vanish } return covering.values.toList() } + +/** [deletionsCovering] with the local store as the deletion source (the "up" direction). */ +suspend fun IEventStore.deletionsCovering( + serverEvents: List, + relay: NormalizedRelayUrl, +): List = deletionsCovering(serverEvents, relay) { query(it) } From 0145f8bdcb518c3cb1c4d8847a8af29d8240312b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 14:42:20 +0000 Subject: [PATCH 09/12] refactor: extract deletion-settle loop into a quartz INostrClient accessory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two-pass deletion convergence is protocol logic, not CLI assembly, and the geode mirror is a near-term second consumer — so move it out of SyncCommand into a reusable accessory alongside the rest of the negentropy family. quartz: negentropySettleDeletions(relay, filter, store, sendUp, applyDown, …) — re-reconciles after a content settle and resolves only the residual: publishes our covering deletions up (sendUp) and/or ingests the relay's kind-5 down (applyDown, vanish never auto-applied), looping until a round resolves nothing. Returns DeletionSettleResult(sentUp, appliedDown, rounds). Everything it needs is already quartz (negentropyReconcileIds, fetchAll, deletionsCovering, publishAndConfirm, Event.verify, IEventStore), so it carries no CLI dependency. SyncCommand's pass 2 collapses to a single call; pass 1 (content) is unchanged. Catalogued in the accessories README. Tests: DeletionSyncTest drives the accessory end-to-end both ways (sendUp → relay converges to gone; applyDown → local converges to gone), on top of the existing deletionsCovering unit + manual-wiring cases. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../amethyst/cli/commands/SyncCommand.kt | 99 +++--------- .../vitorpamplona/geode/DeletionSyncTest.kt | 68 ++++++++ .../NostrClientNegentropyDeletionSettleExt.kt | 145 ++++++++++++++++++ .../relay/client/accessories/README.md | 1 + 4 files changed, 239 insertions(+), 74 deletions(-) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt index 540b5c9894..48a152daa6 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/SyncCommand.kt @@ -26,15 +26,13 @@ import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.DeletionSettleResult import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.NegentropySyncException -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.fetchAll import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcile -import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.store.IdAndTime -import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering -import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.joinAll @@ -186,74 +184,27 @@ object SyncCommand { return Output.error("sync_error", e.message ?: "negentropy sync failed") } - // ── Pass 2+: deletion settle. After the content pass, a re-reconcile's - // residual is (barring races) exactly the events a deletion kept from moving: - // - a residual NEED (relay has it, we still lack it after trying to download) - // = we deleted it → publish OUR covering deletion up so the relay drops it; - // - a residual HAVE (we have it, relay still lacks it after trying to upload) - // = the relay deleted it → pull the relay's covering kind-5 down and apply. - // The residual is tiny (only real deletion mismatches), so this is cheap no - // matter how large the database is — we only fetch metadata for the residual, - // never the whole need set. Loop until a round resolves nothing (converged) or - // we hit the round cap. Best-effort: a failed reconcile here never fails the - // command — the content sync already succeeded. - var deletionsUp = 0 - var deletionsDown = 0 - var deletionRounds = 0 - if (syncDeletions && (down || up)) { - val sentUp = HashSet() - val appliedDown = HashSet() - try { - while (deletionRounds < MAX_DELETION_ROUNDS) { - deletionRounds++ - val diff = - ctx.client.negentropyReconcileIds( - relay = relay, - filter = filter, - localEntries = ctx.store.snapshotIdsForNegentropy(listOf(filter)), - batchSize = ID_CHUNK, - idleTimeoutMs = timeoutMs, - reconcileConcurrency = RECONCILE_CONCURRENCY, - ) - var resolved = 0 - - // residual needs → send our deletions up (bounded: --down settled - // every need we don't have a deletion for). - if (down) { - for (chunk in diff.needIds.chunked(ID_CHUNK)) { - val events = ctx.client.fetchAll(relay, Filter(ids = chunk), timeoutMs) - for (del in ctx.store.deletionsCovering(events, relay)) { - if (sentUp.add(del.id) && ctx.publish(del, setOf(relay)).values.any { it }) { - deletionsUp++ - resolved++ - } - } - } - } - - // residual haves → apply the relay's deletions locally (bounded: - // --up settled every have the relay didn't delete). Only precise - // kind-5 deletions are pulled down; a kind-62 vanish is NOT - // auto-applied (its blast radius is the whole account). - if (up) { - for (chunk in diff.haveIds.chunked(ID_CHUNK)) { - val ourEvents = ctx.store.query(Filter(ids = chunk)) - val relayDeletions = deletionsCovering(ourEvents, relay) { f -> ctx.client.fetchAll(relay, f, timeoutMs) } - for (del in relayDeletions.filterIsInstance()) { - if (appliedDown.add(del.id) && ctx.verifyAndStore(del)) { - deletionsDown++ - resolved++ - } - } - } - } - - if (resolved == 0) break - } - } catch (e: NegentropySyncException) { - // content already synced; deletion convergence is best-effort. + // ── Pass 2+: deletion settle. The reusable quartz accessory re-reconciles + // and resolves only the residual — send our deletions up for what we deleted + // (bounded by --down), apply the relay's kind-5 down for what it deleted + // (bounded by --up) — looping until stable. Cheap regardless of database size + // (see negentropySettleDeletions), and best-effort so it can't fail the sync. + val deletions = + if (syncDeletions) { + ctx.client.negentropySettleDeletions( + relay = relay, + filter = filter, + store = ctx.store, + sendUp = down, + applyDown = up, + batchSize = ID_CHUNK, + idleTimeoutMs = timeoutMs, + maxRounds = MAX_DELETION_ROUNDS, + reconcileConcurrency = RECONCILE_CONCURRENCY, + ) + } else { + DeletionSettleResult(0, 0, 0) } - } Output.emit( mapOf( @@ -264,9 +215,9 @@ object SyncCommand { "have" to result.haveCount, "downloaded" to downloaded.get(), "uploaded" to uploaded.get(), - "deletions_sent_up" to deletionsUp, - "deletions_applied_down" to deletionsDown, - "deletion_rounds" to deletionRounds, + "deletions_sent_up" to deletions.sentUp, + "deletions_applied_down" to deletions.appliedDown, + "deletion_rounds" to deletions.rounds, ), ) return 0 diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt index 90327e529a..f3a7ef4306 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSyncTest.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.geode.testing.publish import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer @@ -196,4 +197,71 @@ class DeletionSyncTest : RelayClientTest() { "local applied the pulled deletion and removed the note", ) } + + // ---- the full accessory loop (negentropySettleDeletions) ----------------- + + // sendUp: local holds the deletion, relay still has the note → the loop pushes it + // up and the relay converges to gone. + @Test + fun settleSendsOurDeletionUp() = + runBlocking { + val target = note("settle up") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + val localStore = EventStore(null) + localStore.insert(target) + localStore.insert(deletion) // deletes target locally, keeps the kind-5 + defaultRelay.preload(listOf(target)) + + val res = + withTimeout(30_000) { + client.negentropySettleDeletions( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + store = localStore, + sendUp = true, + applyDown = false, + idleTimeoutMs = 20_000, + ) + } + + assertEquals(1, res.sentUp) + assertEquals(0, res.appliedDown) + assertTrue( + defaultRelay.store.query(Filter(ids = listOf(target.id))).isEmpty(), + "relay converged: the deleted note is gone", + ) + localStore.close() + } + + // applyDown: relay deleted the note (holds only the kind-5), local still has it → + // the loop pulls the relay's deletion down and local converges to gone. + @Test + fun settleAppliesRelayDeletionDown() = + runBlocking { + val target = note("settle down") + val deletion = signer.sign(DeletionEvent.build(listOf(target), createdAt = target.createdAt + 1)) + defaultRelay.preload(listOf(target, deletion)) // relay deletes target, keeps the kind-5 + val localStore = EventStore(null) + localStore.insert(target) + + val res = + withTimeout(30_000) { + client.negentropySettleDeletions( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + store = localStore, + sendUp = false, + applyDown = true, + idleTimeoutMs = 20_000, + ) + } + + assertEquals(0, res.sentUp) + assertEquals(1, res.appliedDown) + assertTrue( + localStore.query(Filter(ids = listOf(target.id))).isEmpty(), + "local converged: the relay-deleted note is gone", + ) + localStore.close() + } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt new file mode 100644 index 0000000000..67e770f1de --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/NostrClientNegentropyDeletionSettleExt.kt @@ -0,0 +1,145 @@ +/* + * 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.quartz.nip01Core.relay.client.accessories + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.store.IEventStore +import com.vitorpamplona.quartz.nip01Core.store.deletionsCovering +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent + +/** + * Outcome of a [negentropySettleDeletions] run. + * + * @property sentUp distinct local deletions published to the relay (up direction). + * @property appliedDown distinct relay deletions ingested into [store] (down direction). + * @property rounds reconcile rounds run before convergence (or the cap). + */ +class DeletionSettleResult( + val sentUp: Int, + val appliedDown: Int, + val rounds: Int, +) + +/** + * Converge deletions between [store] and [relay] AFTER a content sync has settled the + * two sides — the second half of a two-pass sync. NIP-77 reconciles by id, so a plain + * content sync converges everything except events a deletion physically stops from + * moving; those survive as the reconcile's residual, which this resolves: + * + * - **[sendUp]** — a residual **need** (relay has it, [store] still lacks it after the + * content pass tried to download it) means we deleted it. Publish OUR covering + * deletion up ([IEventStore.deletionsCovering]) so the relay drops it. + * - **[applyDown]** — a residual **have** ([store] has it, relay still lacks it after + * the content pass tried to upload it) means the relay deleted it. Pull the RELAY'S + * covering **kind-5** down and ingest it, so [store] drops it too. A NIP-62 vanish is + * deliberately NOT applied on pull — its blast radius is the author's whole account. + * + * Because it works off the residual — not every id — the cost is one cheap reconcile + * per round plus the (small) residual, independent of database size. It loops until a + * round resolves nothing (converged, and thereby self-verified) or [maxRounds] is hit. + * + * **Direction requires the matching content pass.** A residual need is a clean signal + * only after the content sync attempted the download ([sendUp] pairs with a `--down` + * content pass); a residual have only after it attempted the upload ([applyDown] pairs + * with `--up`). Passing a direction whose content pass didn't run makes its residual the + * full unsettled set, not a deletion signal — so drive this with the same directions the + * content pass used. + * + * Best-effort: a reconcile failure ([NegentropySyncException]) stops the loop and returns + * what already settled rather than throwing — the content sync is the primary work. + * + * @param batchSize ids per reconcile chunk and per by-id fetch. + * @param idleTimeoutMs idle watchdog for the reconciles and fetches. + * @param maxRounds hard cap on rounds; the "resolved nothing" check usually stops first. + * @param reconcileConcurrency overlapped `created_at`-window reconciles after an over-cap split. + */ +suspend fun INostrClient.negentropySettleDeletions( + relay: NormalizedRelayUrl, + filter: Filter, + store: IEventStore, + sendUp: Boolean, + applyDown: Boolean, + batchSize: Int = 500, + idleTimeoutMs: Long = 120_000L, + maxRounds: Int = 4, + reconcileConcurrency: Int = 1, +): DeletionSettleResult { + if ((!sendUp && !applyDown) || maxRounds <= 0) return DeletionSettleResult(0, 0, 0) + + val publishTimeoutSecs = (idleTimeoutMs / 1000).coerceAtLeast(1) + val sentUp = HashSet() + val appliedDown = HashSet() + var rounds = 0 + + while (rounds < maxRounds) { + rounds++ + val diff = + try { + negentropyReconcileIds( + relay = relay, + filter = filter, + localEntries = store.snapshotIdsForNegentropy(listOf(filter)), + batchSize = batchSize, + idleTimeoutMs = idleTimeoutMs, + reconcileConcurrency = reconcileConcurrency, + ) + } catch (e: NegentropySyncException) { + break + } + + var resolved = 0 + + // residual needs → publish our covering deletions up. + if (sendUp) { + for (chunk in diff.needIds.chunked(batchSize)) { + val events = fetchAll(relay, Filter(ids = chunk), idleTimeoutMs) + for (del in store.deletionsCovering(events, relay)) { + if (sentUp.add(del.id)) { + if (publishAndConfirm(del, setOf(relay), publishTimeoutSecs)) resolved++ + } + } + } + } + + // residual haves → ingest the relay's covering kind-5 (never a vanish). + if (applyDown) { + for (chunk in diff.haveIds.chunked(batchSize)) { + val ours = store.query(Filter(ids = chunk)) + val relayDeletions = deletionsCovering(ours, relay) { f -> fetchAll(relay, f, idleTimeoutMs) } + for (del in relayDeletions.filterIsInstance()) { + if (del.verify() && appliedDown.add(del.id)) { + store.insert(del) + resolved++ + } + } + } + } + + if (resolved == 0) break + } + + return DeletionSettleResult(sentUp.size, appliedDown.size, rounds) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md index dbf9fbcf63..46ce631095 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip01Core/relay/client/accessories/README.md @@ -52,6 +52,7 @@ Import as `com.vitorpamplona.quartz.nip01Core.relay.client.accessories.` ( | `negentropySyncEvents` / `negentropySyncOrFetchEvents` | `NostrClientNegentropySyncEventsExt` | The two above as an O(1)-memory `Flow`. | | `negentropyReconcile(relay, filter, localEntries, onNeedIds, onHaveIds)` | `NostrClientNegentropySyncExt` | **Pure diff, no I/O** — streams the two directions (`need` = relay has & we lack; `have` = we have & relay lacks) to callbacks. Compose your own download/upload on top. | | `negentropyReconcileIds(relay, filter, localEntries)` | `NostrClientNegentropySyncExt` | Same diff, materialized into `needIds` / `haveIds` lists (small sets only). | +| `negentropySettleDeletions(relay, filter, store, sendUp, applyDown)` | `NostrClientNegentropyDeletionSettleExt` | Second pass of a two-pass sync: after a content sync settles, re-reconcile and resolve only the residual — send our covering deletions up (`sendUp`) and/or apply the relay's kind-5 down (`applyDown`), looping until stable. Cost is O(residual), not O(db). Pairs with `IEventStore.deletionsCovering`. | `fetchByIds`, `reconcileStreaming`, `syncPipeline` in `NostrClientNegentropySyncExt` are `internal` implementation details — not part of the public surface. From c011dfca6ec5a04357eff15c47d3437ebc7ab6f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:09:57 +0000 Subject: [PATCH 10/12] test(cli): headless end-to-end for deletion sync (real amy vs amy serve) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drives the built `amy` binary against a real `amy serve` relay to prove NIP-77 deletion propagation end-to-end — the running answer to "does it actually work", on top of the in-process geode tests: T1 (up) we deleted a note the relay still has → `amy sync` sends our kind-5 up and the relay drops it (checked by an isolated third account that reads the relay only, so no local tombstone masks the result). T2 (off) `--no-sync-deletions` sends nothing and the relay keeps the note. T3 (down) the relay deleted a note we still hold → `amy sync --up` pulls the relay's kind-5 down and applies it locally; a second sync converges. Each amy account gets its own $HOME (accounts under one $HOME share the file store). Follows the cli/tests/*-headless.sh pattern; state dir gitignored. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- cli/tests/.gitignore | 1 + cli/tests/sync/sync-deletions-headless.sh | 196 ++++++++++++++++++++++ 2 files changed, 197 insertions(+) create mode 100755 cli/tests/sync/sync-deletions-headless.sh diff --git a/cli/tests/.gitignore b/cli/tests/.gitignore index 3d5dc27cf2..7f43ea55a5 100644 --- a/cli/tests/.gitignore +++ b/cli/tests/.gitignore @@ -3,3 +3,4 @@ marmot/state-headless/ dm/state-dm-headless/ nests/state/ clink/state-clink-headless/ +sync/state-sync-deletions/ diff --git a/cli/tests/sync/sync-deletions-headless.sh b/cli/tests/sync/sync-deletions-headless.sh new file mode 100755 index 0000000000..871649dc5e --- /dev/null +++ b/cli/tests/sync/sync-deletions-headless.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# +# sync-deletions-headless.sh — drives the real `amy` binary against a real +# `amy serve` relay to prove NIP-77 deletion propagation end-to-end. +# +# `amy sync` converges deletions in a second pass over the reconcile residual +# (see quartz `negentropySettleDeletions`). This exercises both directions plus +# the opt-out: +# +# T1 (up) — we deleted a note the relay still has → `amy sync` sends our +# kind-5 up and the relay drops the note. Verified by an ISOLATED +# third account whose store reads the relay only (no tombstone). +# T2 (off) — same setup with `--no-sync-deletions` → the relay keeps the note +# and nothing is sent. +# T3 (down) — the relay deleted a note we still hold → `amy sync --up` pulls the +# relay's kind-5 down and applies it locally (converges on re-sync). +# +# Each amy account gets its OWN $HOME so their file stores don't share (accounts +# under one $HOME share ~/.amy/shared/events-store). The relay (amy serve) keeps +# a separate store from any client store. +# +# Usage: ./sync-deletions-headless.sh [--port N] [--no-build] +set -uo pipefail + +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd -- "$SCRIPT_DIR/../../.." && pwd)" +TESTS_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" +STATE_DIR="$SCRIPT_DIR/state-sync-deletions" +LOG_DIR="$STATE_DIR/logs" +RUN_TS="$(date +%Y%m%d-%H%M%S)" +LOG_FILE="$LOG_DIR/run-$RUN_TS.log" +RESULTS_FILE="$STATE_DIR/results-$RUN_TS.tsv" + +AMY_BIN="$REPO_ROOT/cli/build/install/amy/bin/amy" +RELAY_HOST="127.0.0.1" +RELAY_PORT="${RELAY_PORT:-7790}" +RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT" +NO_BUILD=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --port) RELAY_PORT="$2"; RELAY_URL="ws://$RELAY_HOST:$RELAY_PORT"; shift ;; + --no-build) NO_BUILD=1 ;; + *) echo "unknown arg: $1" >&2; exit 2 ;; + esac + shift +done + +# Fresh state every run — stale per-account $HOME dirs from a prior run must not +# leak into this one. +rm -rf "$STATE_DIR" +mkdir -p "$LOG_DIR" +: >"$RESULTS_FILE" + +# shellcheck source=../lib.sh +source "$TESTS_DIR/lib.sh" + +# Leniently-trimmed equality assertion (assert helpers live in the DM-specific +# helpers.sh, which hardcodes its own amy wrappers — so define our own here). +assert_eq() { + local actual="$1" expected="$2" test_id="$3" note="${4:-}" + if [[ "${actual// /}" == "${expected// /}" ]]; then + info "assert: $test_id \"$actual\" == \"$expected\"" + return 0 + fi + fail_msg "$test_id: expected \"$expected\", got \"$actual\" (${note:-})" + record_result "$test_id" fail "${note:-mismatch}" + return 1 +} + +SERVE_PID="" +RELAY_HOME="" +cleanup() { + [[ -n "$SERVE_PID" ]] && kill "$SERVE_PID" 2>/dev/null + trap - EXIT INT TERM HUP + print_summary +} +trap cleanup EXIT +trap 'exit 130' INT +trap 'exit 143' TERM + +banner "amy sync — NIP-77 deletion propagation headless ($RUN_TS)" + +# ---- build ------------------------------------------------------------------ +if [[ "$NO_BUILD" -eq 0 ]]; then + step "Building amy (installDist)…" + (cd "$REPO_ROOT" && ./gradlew -q :cli:installDist) >>"$LOG_FILE" 2>&1 \ + || { fail_msg "build failed (see $LOG_FILE)"; exit 1; } +fi +[[ -x "$AMY_BIN" ]] || { fail_msg "amy binary not found at $AMY_BIN"; exit 1; } + +# ---- amy wrappers (one isolated $HOME per account) -------------------------- +strip() { grep -vE "Picked up JAVA_TOOL|DEBUG:|INFO:|MarmotManager|MlsGroup"; } +mk_home() { mktemp -d "$STATE_DIR/home.XXXXXX"; } +# amy_run args... +amy_run() { + local home="$1" acct="$2"; shift 2 + HOME="$home" "$AMY_BIN" --account "$acct" --secret-backend plaintext --json "$@" 2>>"$LOG_FILE" | strip +} + +RELAY_HOME="$(mk_home)" +amy_run "$RELAY_HOME" a init >/dev/null + +step "Starting amy serve on $RELAY_URL…" +HOME="$RELAY_HOME" "$AMY_BIN" --account a --secret-backend plaintext \ + serve --host "$RELAY_HOST" --port "$RELAY_PORT" >>"$LOG_FILE" 2>&1 & +SERVE_PID=$! + +# Wait for the relay to accept connections (poll the serve log). +for _ in $(seq 1 60); do + grep -q "relay up at" "$LOG_FILE" && break + sleep 0.5 +done +grep -q "relay up at" "$LOG_FILE" || { fail_msg "relay did not come up"; exit 1; } + +# Isolated verifier: its own empty store, reads the relay only (no tombstone). +VERIFY_HOME="$(mk_home)" +amy_run "$VERIFY_HOME" v init >/dev/null +relay_count() { amy_run "$VERIFY_HOME" v fetch --id "$1" --relay "$RELAY_URL" | jq -r '.count // 0'; } + +# ============================================================================= +# T1 — up direction: we deleted it, the relay still has it → sync sends it up. +# ============================================================================= +banner "T1 — amy sync sends our deletion up (relay drops the note)" +NOTE="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t1" | jq -c '.event')" +NID="$(echo "$NOTE" | jq -r '.id')" +echo "$NOTE" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null + +before="$(relay_count "$NID")" +assert_eq "$before" "1" T1.setup "relay should hold the note before sync" \ + && record_result T1.setup pass "relay has the note" + +# Delete locally only (no --relay → stored, applied, not sent to the relay). +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID\"]]" --content "" --publish >/dev/null + +SYNC="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL")" +info "sync: $SYNC" +sent="$(echo "$SYNC" | jq -r '.deletions_sent_up // 0')" +assert_eq "$sent" "1" T1.sent_up "sync should report one deletion sent up" \ + && record_result T1.sent_up pass "deletions_sent_up=1" + +sleep 1 +after="$(relay_count "$NID")" +assert_eq "$after" "0" T1.relay_dropped "relay must have removed the note after sync" \ + && record_result T1.relay_dropped pass "relay note count 1 → 0" + +# ============================================================================= +# T2 — opt-out: --no-sync-deletions leaves the relay untouched. +# ============================================================================= +banner "T2 — --no-sync-deletions propagates nothing" +NOTE2="$(amy_run "$RELAY_HOME" a event --kind 1 --content "keep-me-t2" | jq -c '.event')" +NID2="$(echo "$NOTE2" | jq -r '.id')" +echo "$NOTE2" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID2\"]]" --content "" --publish >/dev/null + +SYNC2="$(amy_run "$RELAY_HOME" a sync --relay "$RELAY_URL" --no-sync-deletions)" +info "sync: $SYNC2" +sent2="$(echo "$SYNC2" | jq -r '.deletions_sent_up // 0')" +assert_eq "$sent2" "0" T2.no_send "--no-sync-deletions must send nothing" \ + && record_result T2.no_send pass "deletions_sent_up=0" +sleep 1 +kept="$(relay_count "$NID2")" +assert_eq "$kept" "1" T2.relay_kept "relay must still hold the note" \ + && record_result T2.relay_kept pass "relay note untouched" + +# ============================================================================= +# T3 — down direction: the relay deleted it, we still hold it → sync --up pulls +# the relay's deletion down and applies it locally. +# ============================================================================= +banner "T3 — amy sync --up applies the relay's deletion locally" +BOB_HOME="$(mk_home)" +amy_run "$BOB_HOME" b init >/dev/null +NOTE3="$(amy_run "$RELAY_HOME" a event --kind 1 --content "delete-me-t3" | jq -c '.event')" +NID3="$(echo "$NOTE3" | jq -r '.id')" +echo "$NOTE3" | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null +# bob's isolated store learns the note from the relay… +amy_run "$BOB_HOME" b fetch --id "$NID3" --relay "$RELAY_URL" >/dev/null +# …then the relay deletes it (author pushes a kind-5 straight to the relay). +amy_run "$RELAY_HOME" a event --kind 5 --tags "[[\"e\",\"$NID3\"]]" --content "" | jq -c '.event' \ + | amy_run "$RELAY_HOME" a publish --relay "$RELAY_URL" >/dev/null + +SYNC3="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")" +info "sync: $SYNC3" +applied="$(echo "$SYNC3" | jq -r '.deletions_applied_down // 0')" +assert_eq "$applied" "1" T3.applied_down "sync --up should apply one relay deletion locally" \ + && record_result T3.applied_down pass "deletions_applied_down=1" + +# Converged: a second --up sync finds nothing left to apply. +SYNC3B="$(amy_run "$BOB_HOME" b sync --up --relay "$RELAY_URL")" +applied2="$(echo "$SYNC3B" | jq -r '.deletions_applied_down // 0')" +assert_eq "$applied2" "0" T3.converged "re-sync applies nothing (converged)" \ + && record_result T3.converged pass "second sync stable" + +# print_summary runs from the cleanup trap; exit non-zero if any test failed. +grep -q $'\tfail\t' "$RESULTS_FILE" && exit 1 +exit 0 From 4ffc56829a3939025d8c5890c8c4a6135b16009b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:19:30 +0000 Subject: [PATCH 11/12] test(geode): benchmark deletion-settle cost is O(residual), not O(database) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit relayBench measures relay-to-relay reconcile, not the amy/quartz client feature, so the deletion-settle perf claim belongs in an in-process benchmark of negentropySettleDeletions itself. Models the post-content-settle state: a relay with N notes, a local store with the same N except K it deleted (keeping the K kind-5s). The reconcile residual is exactly those K, so a sendUp settle fetches K — not N. Asserts residual==K, sentUp==K, and relay convergence (correctness guard at the small default N), and prints one-reconcile vs full-settle so the deletion overhead reads as "a few reconciles + K", never "+ a content re-download". Measured: N=2000 K=20: settle ~2x one reconcile, fetched K=20 not N N=100000 K=20: settle ~5x one reconcile, fetched K=20 not N=100000 The growth is the relay rebuilding its negentropy index after the deletions (O(N) once) — inherent to applying deletions, and still far cheaper than re-fetching the need set, which the old per-need-fetch approach did. Scale with -DdelBenchN / -DdelBenchK (forwarded by the geode test task). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- geode/build.gradle.kts | 3 + .../geode/DeletionSettleBenchmark.kt | 120 ++++++++++++++++++ 2 files changed, 123 insertions(+) create mode 100644 geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt diff --git a/geode/build.gradle.kts b/geode/build.gradle.kts index 594458773f..ee636965c7 100644 --- a/geode/build.gradle.kts +++ b/geode/build.gradle.kts @@ -72,6 +72,9 @@ tasks.withType().configureEach { // NegentropyServerReconcileBenchmark opt-in + sizing. System.getProperty("negServerBench")?.let { systemProperty("negServerBench", it) } System.getProperty("negBenchN")?.let { systemProperty("negBenchN", it) } + // DeletionSettleBenchmark sizing. + System.getProperty("delBenchN")?.let { systemProperty("delBenchN", it) } + System.getProperty("delBenchK")?.let { systemProperty("delBenchK", it) } // MirrorSyncThroughputTest sizing + external-source opt-in. System.getProperty("syncN")?.let { systemProperty("syncN", it) } System.getProperty("syncExpect")?.let { systemProperty("syncExpect", it) } diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt new file mode 100644 index 0000000000..109163fddd --- /dev/null +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt @@ -0,0 +1,120 @@ +/* + * 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.geode + +import com.vitorpamplona.geode.testing.RelayClientTest +import com.vitorpamplona.geode.testing.preload +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropyReconcileIds +import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.negentropySettleDeletions +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerSync +import com.vitorpamplona.quartz.nip01Core.store.sqlite.EventStore +import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlin.test.AfterTest +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Cost of the deletion side-channel ([negentropySettleDeletions]) at database scale. + * + * The whole point of the two-pass design is that turning deletions on does NOT re-fetch + * content — the content sync already downloaded the need set, and the settle only touches + * the reconcile *residual* (the events a deletion stopped from converging). So its cost is + * one reconcile per round plus the residual, independent of how big the database is. + * + * This models the post-content-settle state: a relay holding N notes, and a local store + * holding the same N notes EXCEPT K it deleted (it keeps the K kind-5s). The residual is + * exactly those K — so a `sendUp` settle fetches K, not N. It prints the reconcile cost + * (the O(N) part it shares with any sync) next to the settle cost, so the deletion + * overhead is visible as "≈ a couple of reconciles + K", not "+ a content re-download". + * + * Default N is small so it doubles as a fast correctness guard; scale it with + * `-DdelBenchN=200000` to see the shape at size. Not a speed assertion (container noise). + */ +class DeletionSettleBenchmark : RelayClientTest() { + private val signer = NostrSignerSync(KeyPair()) + private val local = EventStore(null) + + @AfterTest fun closeLocal() = local.close() + + private val n = System.getProperty("delBenchN")?.toInt() ?: 2_000 + private val k = System.getProperty("delBenchK")?.toInt() ?: 20 + + @Test + fun settleCostIsResidualNotDatabase() = + runBlocking { + val base = TimeUtils.now() - n + // N notes with monotonic created_at (sorted order == index order). + val notes = (0 until n).map { signer.sign(TextNoteEvent.build("n$it", createdAt = base + it.toLong())) } + // The last K are the ones we deleted locally. + val deleted = notes.takeLast(k) + val kept = notes.dropLast(k) + val deletions = deleted.map { signer.sign(DeletionEvent.build(listOf(it), createdAt = it.createdAt + 1)) } + + // Relay holds all N notes; we hold the N-K we didn't delete, plus the K kind-5s. + defaultRelay.preload(notes) + kept.forEach { local.insert(it) } + deletions.forEach { local.insert(it) } + assertEquals(n - k, local.query(Filter(kinds = listOf(1))).size, "local kept N-K notes") + + // Cost of one reconcile — the O(N) work every sync round already does. + val r0 = System.nanoTime() + val diff = + withTimeout(120_000) { + client.negentropyReconcileIds(defaultRelayUrl, Filter(kinds = listOf(1)), local.snapshotIdsForNegentropy(listOf(Filter(kinds = listOf(1))))) + } + val reconcileMs = (System.nanoTime() - r0) / 1e6 + assertEquals(k, diff.needIds.size, "the residual is exactly the K deleted notes, not N") + + // Cost of the whole settle: reconcile(s) + resolve the K-event residual. + val s0 = System.nanoTime() + val res = + withTimeout(120_000) { + client.negentropySettleDeletions( + relay = defaultRelayUrl, + filter = Filter(kinds = listOf(1)), + store = local, + sendUp = true, + applyDown = false, + idleTimeoutMs = 60_000, + ) + } + val settleMs = (System.nanoTime() - s0) / 1e6 + + assertEquals(k, res.sentUp, "sent exactly K deletions up") + assertEquals( + n - k, + defaultRelay.store.query(Filter(kinds = listOf(1))).size, + "relay converged: the K deleted notes are gone", + ) + + println("─ DeletionSettleBenchmark @ N=$n K=$k ─") + println(" one reconcile: ${"%.1f".format(reconcileMs)} ms (O(N), shared with any sync)") + println(" full settle: ${"%.1f".format(settleMs)} ms (${res.rounds} rounds, sentUp=${res.sentUp})") + println(" deletion cost: settle is ~${"%.1f".format(settleMs / reconcileMs)}× one reconcile — fetched K=$k, not N=$n") + } +} From 01ab0cf0bf46d3f5e9507c3303322a933edffbdd Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 8 Jul 2026 15:48:27 +0000 Subject: [PATCH 12/12] test(geode): keep deletion-settle benchmark as robust shape guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the flaky publish-into-large-relay warmup from DeletionSettleBenchmark (it timed out the measured reconcile at N=100k — the container noise the docstring already warns against) and remove the throwaway ScratchSettleTiming investigation tool. Record in the docstring what the phase breakdown proved: the settle's extra time over a bare reconcile is O(K) relay-ingest of the K residual deletions, dominated by one-time JVM/JIT warmup of the publish path (consecutive K-note batches fell ~3100->570ms), not the deletion algorithm and not O(N). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt --- .../com/vitorpamplona/geode/DeletionSettleBenchmark.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt index 109163fddd..46e4b7a1ca 100644 --- a/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt +++ b/geode/src/test/kotlin/com/vitorpamplona/geode/DeletionSettleBenchmark.kt @@ -52,6 +52,14 @@ import kotlin.test.assertEquals * (the O(N) part it shares with any sync) next to the settle cost, so the deletion * overhead is visible as "≈ a couple of reconciles + K", not "+ a content re-download". * + * Why the printed settle can read as several× a bare reconcile at large N: the extra time + * is NOT the deletion algorithm (a phase breakdown showed reconciles stay ~sub-second at + * N=100k, and the settle re-fetches K=20, not N). It is entirely the K `publishAndConfirm` + * ingests into a large geode relay — publishing K *plain* notes costs the same — and that + * ingest path is JVM-cold on first use: consecutive K-note batches dropped monotonically + * (~3100 → ~570 ms) purely from JIT warmup. So the cost is O(K) relay-ingest dominated by + * one-time warmup, independent of N. + * * Default N is small so it doubles as a fast correctness guard; scale it with * `-DdelBenchN=200000` to see the shape at size. Not a speed assertion (container noise). */