feat: deletions-first ordering + reject-reaction backstop in sync

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgL1WTV4Hkp2uuXcUHCHGt
This commit is contained in:
Claude
2026-07-08 01:25:18 +00:00
parent 683f993c7b
commit f8b2f17979
3 changed files with 146 additions and 42 deletions
@@ -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:<same>}` 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:<same>}` **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<Event>(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<Event>(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<HexKey>()
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<Event>(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(