diff --git a/docs/partial-messages.md b/docs/partial-messages.md index 4c715a7b0..13ac03f73 100644 --- a/docs/partial-messages.md +++ b/docs/partial-messages.md @@ -356,29 +356,39 @@ mergeable. Mirror this checklist in issue #435. -- [ ] **Step 1** — Per-topic `SubOpts` flag plumbing. Outbound: flags added +- [x] **Step 1** — Per-topic `SubOpts` flag plumbing. Outbound: flags added to subscribe announce RPCs. Inbound: parse flags into a `PartialTopicState` (`Map>`). Coercion rule applied on receive. Flags ignored on `subscribe=false`. -- [ ] **Step 2** — `PartialMessagesHandler` interface, + → [PR #460](https://github.com/libp2p/jvm-libp2p/pull/460) +- [x] **Step 2** — `PartialMessagesHandler` interface, `PublishAction` (with `nextPeerState`), `PublishActionsFn`, `PartialMessagesPeerFeedback`, and `GroupState` container with TTL + DoS caps. No routing yet. -- [ ] **Step 3** — Inbound `RPC.partial` dispatch: replace the stub at + → [PR #461](https://github.com/libp2p/jvm-libp2p/pull/461) +- [x] **Step 3** — Inbound `RPC.partial` dispatch: replace the stub at `GossipRouter.kt:476` with the full flow (validate caps, create/update group state, call `onIncomingRpc`). -- [ ] **Step 4** — Outbound `publishPartial(...)` on the `Gossip` facade; + → [PR #463](https://github.com/libp2p/jvm-libp2p/pull/463) +- [x] **Step 4** — Outbound `publishPartial(...)` on the `Gossip` facade; route through `GossipRpcPartsQueue` (do **not** bypass — PR #433 got this wrong). Enforce the "omit `partialMessage` when peer supports but didn't request" MUST. -- [ ] **Step 5** — End-to-end integration test with a trivial bitmap-based + → [PR #465](https://github.com/libp2p/jvm-libp2p/pull/465) +- [x] **Step 5** — End-to-end integration test with a trivial bitmap-based handler. Exercises Steps 1-4 before any routing changes. -- [ ] **Step 6** — Routing: full-message suppression (§5.1). -- [ ] **Step 7** — Routing: IDONTWANT suppression (§5.2). -- [ ] **Step 8** — Heartbeat tick + TTL GC + cleanup hooks (§6.4). -- [ ] **Step 9** — Routing: IHAVE replacement with `onEmitGossip` (§5.3). -- [ ] **Step 10** — Simulator scenario + mixed-peer interop test (partial + + → [PR #466](https://github.com/libp2p/jvm-libp2p/pull/466) +- [x] **Step 6** — Routing: full-message suppression (§5.1). + → [PR #467](https://github.com/libp2p/jvm-libp2p/pull/467) +- [x] **Step 7** — Routing: IDONTWANT suppression (§5.2). + → [PR #468](https://github.com/libp2p/jvm-libp2p/pull/468) +- [x] **Step 8** — Heartbeat tick + TTL GC + cleanup hooks (§6.4). + → [PR #469](https://github.com/libp2p/jvm-libp2p/pull/469) +- [x] **Step 9** — Routing: IHAVE replacement with `onEmitGossip` (§5.3). + → [PR #470](https://github.com/libp2p/jvm-libp2p/pull/470) +- [x] **Step 10** — Simulator scenario + mixed-peer interop test (partial + non-partial nodes on the same topic). + → [PR #471](https://github.com/libp2p/jvm-libp2p/pull/471) --- diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt index a72b93ccf..0fee53645 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/AbstractRouter.kt @@ -147,11 +147,22 @@ abstract class AbstractRouter( override fun onPeerActive(peer: PeerHandler) { val partsQueue = pendingRpcParts.getQueue(peer) subscribedTopics.forEach { - partsQueue.addSubscribe(it) + enqueueSubscribe(partsQueue, it) } flushPending(peer) } + /** + * Enqueues a subscribe announcement for [topic] onto [partsQueue]. + * + * The default implementation emits a bare subscribe with no per-topic options. + * Subclasses (e.g. GossipRouter) override this to attach per-topic options + * such as partial-message flags. + */ + protected open fun enqueueSubscribe(partsQueue: RpcPartsQueue, topic: Topic) { + partsQueue.addSubscribe(topic) + } + protected open fun notifyMalformedMessage(peer: PeerHandler) {} protected open fun notifyUnseenMessage(peer: PeerHandler, msg: PubsubMessage) {} protected open fun notifyNonSubscribedMessage(peer: PeerHandler, msg: Rpc.Message) {} @@ -172,7 +183,17 @@ abstract class AbstractRouter( } try { - val subscriptions = msg.subscriptionsList.map { PubsubSubscription(it.topicid, it.subscribe) } + val subscriptions = msg.subscriptionsList.map { + // Per partial-messages spec: flags MUST be ignored on subscribe=false, and the + // receiving side coerces supportsSendingPartial := requestsPartial || supportsSendingPartial. + // The coercion rule is also applied on the outbound side by GossipRouter. + PubsubSubscription( + topic = it.topicid, + subscribe = it.subscribe, + requestsPartial = it.subscribe && it.requestsPartial, + supportsSendingPartial = it.subscribe && (it.supportsSendingPartial || it.requestsPartial) + ) + } subscriptionFilter .filterIncomingSubscriptions(subscriptions, peersTopics.getByFirst(peer)) .forEach { handleMessageSubscriptions(peer, it) } @@ -301,7 +322,20 @@ abstract class AbstractRouter( } } - private fun handleMessageSubscriptions(peer: PeerHandler, msg: PubsubSubscription) { + /** + * Applies a single filtered inbound subscription to the router's state. + * + * Called once per `SubOpts` on the pubsub event loop, after + * [SubscriptionFilter.filterIncomingSubscriptions] has run. Subclasses may + * override to react to subscription state changes (for example, to track + * per-topic capability flags). Overrides MUST call `super` so that + * [peersTopics] stays in sync. + * + * [msg] carries the protocol-level flags already normalised by the caller: + * for `subscribe=false` frames, extension flags are zeroed before reaching + * this method. + */ + protected open fun handleMessageSubscriptions(peer: PeerHandler, msg: PubsubSubscription) { if (msg.subscribe) { peersTopics.add(peer, msg.topic) } else { @@ -319,7 +353,7 @@ abstract class AbstractRouter( } protected open fun subscribe(topic: Topic) { - activePeers.forEach { pendingRpcParts.getQueue(it).addSubscribe(topic) } + activePeers.forEach { enqueueSubscribe(pendingRpcParts.getQueue(it), topic) } subscribedTopics += topic } diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt index 2d421c58c..5cf44ff9c 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubProtocol.kt @@ -26,7 +26,7 @@ enum class PubsubProtocol(val announceStr: ProtocolId) { * https://github.com/libp2p/specs/blob/master/pubsub/gossipsub/gossipsub-v1.2.md#idontwant-message */ fun supportsIDontWant(): Boolean { - return this == Gossip_V_1_2 + return this == Gossip_V_1_2 || this == Gossip_V_1_3 } /** diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRouter.kt index c960fdb68..cce99b608 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/PubsubRouter.kt @@ -13,7 +13,12 @@ typealias Topic = String typealias MessageId = WBytes typealias PubsubMessageFactory = (Rpc.Message) -> PubsubMessage -data class PubsubSubscription(val topic: Topic, val subscribe: Boolean) +data class PubsubSubscription( + val topic: Topic, + val subscribe: Boolean, + val requestsPartial: Boolean = false, + val supportsSendingPartial: Boolean = false +) interface PubsubMessage { val protobufMessage: Rpc.Message diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcPartsQueue.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcPartsQueue.kt index 11af5f8dd..05c6a6234 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcPartsQueue.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/RpcPartsQueue.kt @@ -9,14 +9,23 @@ interface RpcPartsQueue { fun addPublish(message: Rpc.Message) fun addSubscribe(topic: Topic) { - addSubscription(topic, SubscriptionStatus.Subscribed) + addSubscribe(topic, requestsPartial = false, supportsSendingPartial = false) + } + + fun addSubscribe(topic: Topic, requestsPartial: Boolean, supportsSendingPartial: Boolean) { + addSubscription(topic, SubscriptionStatus.Subscribed, requestsPartial, supportsSendingPartial) } fun addUnsubscribe(topic: Topic) { - addSubscription(topic, SubscriptionStatus.Unsubscribed) + addSubscription(topic, SubscriptionStatus.Unsubscribed, requestsPartial = false, supportsSendingPartial = false) } - fun addSubscription(topic: Topic, status: SubscriptionStatus) + fun addSubscription( + topic: Topic, + status: SubscriptionStatus, + requestsPartial: Boolean, + supportsSendingPartial: Boolean + ) fun takeMerged(): List } @@ -38,11 +47,20 @@ open class DefaultRpcPartsQueue : RpcPartsQueue { } } - protected data class SubscriptionPart(val topic: Topic, val status: RpcPartsQueue.SubscriptionStatus) : AbstractPart { + protected data class SubscriptionPart( + val topic: Topic, + val status: RpcPartsQueue.SubscriptionStatus, + val requestsPartial: Boolean = false, + val supportsSendingPartial: Boolean = false + ) : AbstractPart { override fun appendToBuilder(builder: Rpc.RPC.Builder) { - builder.addSubscriptionsBuilder().apply { - setTopicid(topic) - setSubscribe(status == RpcPartsQueue.SubscriptionStatus.Subscribed) + val subBuilder = builder.addSubscriptionsBuilder() + subBuilder.topicid = topic + subBuilder.subscribe = status == RpcPartsQueue.SubscriptionStatus.Subscribed + // Per spec: partial flags MUST NOT be sent on unsubscribe (subscribe=false). + if (status == RpcPartsQueue.SubscriptionStatus.Subscribed) { + if (requestsPartial) subBuilder.requestsPartial = true + if (supportsSendingPartial) subBuilder.supportsSendingPartial = true } } } @@ -57,8 +75,13 @@ open class DefaultRpcPartsQueue : RpcPartsQueue { addPart(PublishPart(message)) } - override fun addSubscription(topic: Topic, status: RpcPartsQueue.SubscriptionStatus) { - addPart(SubscriptionPart(topic, status)) + override fun addSubscription( + topic: Topic, + status: RpcPartsQueue.SubscriptionStatus, + requestsPartial: Boolean, + supportsSendingPartial: Boolean + ) { + addPart(SubscriptionPart(topic, status, requestsPartial, supportsSendingPartial)) } override fun takeMerged(): List { diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt index 39100f10c..7628345da 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/Gossip.kt @@ -10,7 +10,9 @@ import io.libp2p.core.multistream.ProtocolDescriptor import io.libp2p.core.pubsub.PubsubApi import io.libp2p.pubsub.PubsubApiImpl import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.Topic import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn import io.netty.channel.ChannelHandler import org.slf4j.LoggerFactory import java.util.concurrent.CompletableFuture @@ -32,6 +34,20 @@ class Gossip @JvmOverloads constructor( return router.score.getCachedScore(peerId) } + /** + * Queues outbound [pubsub.pb.Rpc.PartialMessagesExtension] RPCs for [topic]/[groupId] + * by invoking the client's [actionsFn] on the current group state. + * + * Submits to the pubsub event thread; the returned future completes when the RPCs + * have been enqueued and flushed. + */ + fun publishPartial( + topic: Topic, + groupId: ByteArray, + actionsFn: PublishActionsFn<*> + ): CompletableFuture = + router.submitOnEventThread { router.publishPartial(topic, groupId, actionsFn) } + override val protocolDescriptor = when (router.protocol) { PubsubProtocol.Gossip_V_1_3 -> { diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt index bdfe69055..5e291beda 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRouter.kt @@ -7,6 +7,9 @@ import io.libp2p.core.pubsub.ValidationResult import io.libp2p.etc.types.* import io.libp2p.etc.util.P2PService import io.libp2p.pubsub.* +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesAdapter +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn +import io.libp2p.pubsub.gossip.partialmessages.toGroupId import org.slf4j.LoggerFactory import pubsub.pb.Rpc import java.time.Duration @@ -134,6 +137,56 @@ open class GossipRouter( override val pendingRpcParts = PendingRpcPartsMap { DefaultGossipRpcPartsQueue(params) } val gossipExtensionsState = GossipExtensionsState(gossipExtensionsConfig) + val partialSubscriptionState = PartialSubscriptionState() + internal var partialMessages: PartialMessagesAdapter? = null + + /** + * Local per-topic subscription options that affect outbound subscribe announcements. + * Accessed only on the pubsub event loop. + */ + private val localTopicPartialFlags: MutableMap = mutableMapOf() + + /** + * Configures the partial-messages flags advertised on this node's subscribe + * announcements for [topic]. Must be called before [subscribe] for the flags + * to take effect on the initial announcement; a subsequent call will affect + * later re-announcements (e.g. on new peer activation). + * + * Per spec, the send-side also applies the coercion + * `supportsSendingPartial := requestsPartial || supportsSendingPartial`. + */ + fun setTopicPartialFlags(topic: Topic, requestsPartial: Boolean, supportsSendingPartial: Boolean) { + runOnEventThread { + val coerced = PartialSubFlags.coerce(requestsPartial, supportsSendingPartial) + if (coerced == PartialSubFlags.NONE) { + localTopicPartialFlags -= topic + } else { + localTopicPartialFlags[topic] = coerced + } + } + } + + /** + * Queues outbound [pubsub.pb.Rpc.PartialMessagesExtension] RPCs for [topic]/[groupId] + * by invoking the client's [actionsFn] on the current group state. + * + * Must be called on the pubsub event thread. + */ + fun publishPartial(topic: Topic, groupId: ByteArray, actionsFn: PublishActionsFn<*>) { + val adapter = partialMessages ?: return + val gid = groupId.toGroupId() + + fun peerRequestsPartial(peerId: PeerId) = + partialSubscriptionState.peerRequestsPartial(topic, peerId) + + fun enqueue(peerId: PeerId, partialMessage: ByteArray?, partsMetadata: ByteArray?) { + val peerHandler = activePeers.find { it.peerId == peerId } ?: return + pendingRpcParts.getQueue(peerHandler).addPartialMessage(topic, groupId, partialMessage, partsMetadata) + } + + adapter.publishPartial(topic, gid, actionsFn, ::peerRequestsPartial, ::enqueue) + flushAllPending() + } private fun setBackOff(peer: PeerHandler, topic: Topic) = setBackOff(peer, topic, params.pruneBackoff.toMillis()) private fun setBackOff(peer: PeerHandler, topic: Topic, delay: Long) { @@ -161,9 +214,29 @@ open class GossipRouter( acceptRequestsWhitelist -= peer pendingRpcParts.popQueue(peer) // discard them gossipExtensionsState.onPeerDisconnected(peer.peerId) + partialSubscriptionState.onPeerDisconnected(peer.peerId) + partialMessages?.onPeerDisconnected(peer.peerId) super.onPeerDisconnected(peer) } + override fun enqueueSubscribe(partsQueue: RpcPartsQueue, topic: Topic) { + val flags = localTopicPartialFlags[topic] ?: PartialSubFlags.NONE + partsQueue.addSubscribe(topic, flags.requestsPartial, flags.supportsSendingPartial) + } + + override fun handleMessageSubscriptions(peer: PeerHandler, msg: PubsubSubscription) { + super.handleMessageSubscriptions(peer, msg) + if (msg.subscribe) { + partialSubscriptionState.setPeerFlags( + msg.topic, + peer.peerId, + PartialSubFlags(msg.requestsPartial, msg.supportsSendingPartial) + ) + } else { + partialSubscriptionState.removePeerFlags(msg.topic, peer.peerId) + } + } + override fun onPeerActive(peer: PeerHandler) { super.onPeerActive(peer) eventBroadcaster.notifyConnected(peer.peerId, peer.getRemoteAddress()) @@ -477,12 +550,19 @@ open class GossipRouter( partialMessagesExtension: Rpc.PartialMessagesExtension, receivedFrom: PeerHandler ) { - logger.trace( - "Processing partial message extension message {} from {}", - partialMessagesExtension.toString(), - receivedFrom.peerId - ) - // TODO: implement partial message handling (https://github.com/libp2p/jvm-libp2p/issues/435) + val topic = partialMessagesExtension.topicID + if (!partialMessagesExtension.hasTopicID() || topic.isEmpty()) { + logger.debug("Dropping partial message from {}: missing topicID", receivedFrom.peerId) + return + } + + if (!partialMessagesExtension.hasGroupID() || partialMessagesExtension.groupID.isEmpty) { + logger.debug("Dropping partial message from {}: missing groupID", receivedFrom.peerId) + return + } + + logger.trace("Processing partial message extension for topic {} from {}", topic, receivedFrom.peerId) + partialMessages?.onIncomingRpc(topic, receivedFrom.peerId, partialMessagesExtension) } override fun broadcastInbound(msgs: List, receivedFrom: PeerHandler) { @@ -500,6 +580,7 @@ open class GossipRouter( .plus(peersFromMesh) .distinct() .minus(receivedFrom) + .filterNot { peerRequestsPartialForMessage(it, pubMsg.topics) } .filterNot { peerDoesNotWantMessage(it, pubMsg.messageId) } .forEach { submitPublishMessage(it, pubMsg) } mCache += pubMsg @@ -524,6 +605,7 @@ open class GossipRouter( return if (peers.isNotEmpty()) { iDontWant(msg) val publishedMessages = peers + .filterNot { peerRequestsPartialForMessage(it, msg.topics) } .filterNot { peerDoesNotWantMessage(it, msg.messageId) } .map { submitPublishMessage(it, msg) } if (publishedMessages.isEmpty()) { @@ -615,6 +697,9 @@ open class GossipRouter( super.unsubscribe(topic) mesh[topic]?.copy()?.forEach { prune(it, topic) } mesh -= topic + localTopicPartialFlags -= topic + partialSubscriptionState.removeTopic(topic) + partialMessages?.onTopicUnsubscribed(topic) } private fun catchingHeartbeat() { @@ -717,6 +802,7 @@ open class GossipRouter( } mCache.shift() + partialMessages?.onHeartbeat() flushAllPending() } catch (t: Exception) { @@ -732,9 +818,22 @@ open class GossipRouter( val peers = (getTopicPeers(topic) - excludePeers) .filter { score.score(it.peerId) >= scoreParams.gossipThreshold && !isDirect(it) } - peers.shuffled(random) + val selected = peers.shuffled(random) .take(max((params.gossipFactor * peers.size).toInt(), params.DLazy)) - .forEach { enqueueIhave(it, shuffledMessageIds, topic) } + + // §5.3: partition gossip targets into full-message peers and partial-capable peers. + // Skip IHAVE for partial peers; call onEmitGossip for locally-initiated groups instead. + val adapter = partialMessages + if (adapter != null && localTopicPartialFlags[topic]?.supportsSendingPartial == true) { + val (partialPeers, fullPeers) = selected.partition { peer -> + gossipExtensionsState.peerSupportsPartialMessages(peer.peerId) && + partialSubscriptionState.peerRequestsPartial(topic, peer.peerId) + } + fullPeers.forEach { enqueueIhave(it, shuffledMessageIds, topic) } + adapter.onEmitGossip(topic, partialPeers.map { it.peerId }) + } else { + selected.forEach { enqueueIhave(it, shuffledMessageIds, topic) } + } } private fun graft(peer: PeerHandler, topic: Topic) { @@ -751,6 +850,12 @@ open class GossipRouter( } } + private fun peerRequestsPartialForMessage(peer: PeerHandler, topics: Collection): Boolean { + if (!gossipExtensionsState.partialMessagesEnabled()) return false + if (!gossipExtensionsState.peerSupportsPartialMessages(peer.peerId)) return false + return topics.any { partialSubscriptionState.peerRequestsPartial(it, peer.peerId) } + } + private fun peerDoesNotWantMessage(peer: PeerHandler, messageId: MessageId): Boolean { return peerIDontWant[peer]?.messageIdsAndTimeReceived?.contains(messageId) == true } @@ -771,9 +876,21 @@ open class GossipRouter( .flatten() .distinct() .minus(setOfNotNull(receivedFrom)) + .filterNot { shouldSkipIDontWantForPeer(it, msg.topics) } .forEach { sendIdontwant(it, msg.messageId) } } + // §5.2: skip IDONTWANT to peer P for topic T when we requested partial from P and P supports sending partial. + // Sending IDONTWANT would be redundant — P is expected to send partial RPCs instead of full messages. + private fun shouldSkipIDontWantForPeer(peer: PeerHandler, topics: Collection): Boolean { + if (!gossipExtensionsState.partialMessagesEnabled()) return false + if (!gossipExtensionsState.peerSupportsPartialMessages(peer.peerId)) return false + return topics.any { topic -> + (localTopicPartialFlags[topic]?.requestsPartial == true) && + partialSubscriptionState.peerSupportsSendingPartial(topic, peer.peerId) + } + } + private fun enqueuePrune(peer: PeerHandler, topic: Topic) { val peerQueue = pendingRpcParts.getQueue(peer) if (peer.getPeerProtocol().supportsBackoffAndPX() && this.protocol.supportsBackoffAndPX()) { diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt index 32e5c908a..72b581f32 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/GossipRpcPartsQueue.kt @@ -29,6 +29,8 @@ interface GossipRpcPartsQueue : RpcPartsQueue { // TODO Need to check if we should handle when control extension and extension messages could be separated by split (https://github.com/libp2p/jvm-libp2p/issues/440) fun addControlExtensions(ctrlMessage: Rpc.ControlExtensions) + + fun addPartialMessage(topic: Topic, groupId: ByteArray, partialMessage: ByteArray?, partsMetadata: ByteArray?) } /** @@ -90,6 +92,23 @@ open class DefaultGossipRpcPartsQueue( } } + // Not a data class: ByteArray fields break equals/hashCode in data classes. + protected class PartialMessagePart( + val topic: Topic, + val groupId: ByteArray, + val partialMessage: ByteArray?, + val partsMetadata: ByteArray? + ) : AbstractPart { + override fun appendToBuilder(builder: Rpc.RPC.Builder) { + val pmBuilder = Rpc.PartialMessagesExtension.newBuilder() + .setTopicID(topic) + .setGroupID(groupId.toProtobuf()) + partialMessage?.let { pmBuilder.setPartialMessage(it.toProtobuf()) } + partsMetadata?.let { pmBuilder.setPartsMetadata(it.toProtobuf()) } + builder.setPartial(pmBuilder.build()) + } + } + override fun addIHave(messageId: MessageId, topic: Topic) { addPart(IHavePart(messageId, topic)) } @@ -114,6 +133,10 @@ open class DefaultGossipRpcPartsQueue( addPart(ControlExtensionPart(ctrlMessage)) } + override fun addPartialMessage(topic: Topic, groupId: ByteArray, partialMessage: ByteArray?, partsMetadata: ByteArray?) { + addPart(PartialMessagePart(topic, groupId, partialMessage, partsMetadata)) + } + override fun takeMerged(): List { val ret = mutableListOf() var partIdx = 0 @@ -126,10 +149,12 @@ open class DefaultGossipRpcPartsQueue( var iWantCount = params.maxIWantMessageIds ?: Int.MAX_VALUE var graftCount = params.maxGraftMessages ?: Int.MAX_VALUE var pruneCount = params.maxPruneMessages ?: Int.MAX_VALUE + // proto field `partial` is optional (not repeated): at most 1 per RPC + var partialCount = 1 while (partIdx < parts.size && publishCount > 0 && subscriptionCount > 0 && iHaveCount > 0 && - iWantCount > 0 && graftCount > 0 && pruneCount > 0 + iWantCount > 0 && graftCount > 0 && pruneCount > 0 && partialCount > 0 ) { val part = parts[partIdx++] when (part) { @@ -139,6 +164,7 @@ open class DefaultGossipRpcPartsQueue( is IWantPart -> iWantCount-- is GraftPart -> graftCount-- is PrunePart -> pruneCount-- + is PartialMessagePart -> partialCount-- } part.appendToBuilder(builder) diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/PartialSubscriptionState.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/PartialSubscriptionState.kt new file mode 100644 index 000000000..ae4b7daf8 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/PartialSubscriptionState.kt @@ -0,0 +1,89 @@ +package io.libp2p.pubsub.gossip + +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic + +data class PartialSubFlags( + val requestsPartial: Boolean, + val supportsSendingPartial: Boolean +) { + companion object { + val NONE = PartialSubFlags(requestsPartial = false, supportsSendingPartial = false) + + /** + * Applies the partial-messages spec coercion + * `supportsSendingPartial := requestsPartial || supportsSendingPartial`. + * + * Per the spec, this rule MUST be applied by both the sender (when + * advertising flags outbound) and the receiver (when parsing inbound + * `SubOpts`). Callers are expected to have already zeroed the flags + * for `subscribe=false` frames before calling this helper. + */ + fun coerce(requestsPartial: Boolean, supportsSendingPartial: Boolean): PartialSubFlags = + PartialSubFlags( + requestsPartial = requestsPartial, + supportsSendingPartial = supportsSendingPartial || requestsPartial + ) + } +} + +/** + * Per-topic, per-peer partial-messages subscription state. + * + * Tracks, for each `(topic, peer)`, the remote peer's `requestsPartial` / + * `supportsSendingPartial` flags as most recently announced via a subscribe + * `SubOpts`. Unsubscribes and peer disconnects drop the corresponding state. + * + * NOT thread-safe: accessed only on the pubsub event loop. + */ +class PartialSubscriptionState { + + private val byTopic: MutableMap> = mutableMapOf() + + /** + * Stores [flags] for `(topic, peer)`. + * + * Passing [PartialSubFlags.NONE] (or any equivalent `PartialSubFlags(false, false)`) + * is treated as a removal: the peer's entry is dropped and, if it was the + * last peer for the topic, the topic entry is GC'd. This keeps the snapshot + * invariant "present ⇔ non-default flags". + */ + fun setPeerFlags(topic: Topic, peer: PeerId, flags: PartialSubFlags) { + if (flags == PartialSubFlags.NONE) { + removePeerFlags(topic, peer) + return + } + byTopic.getOrPut(topic) { mutableMapOf() }[peer] = flags + } + + fun removePeerFlags(topic: Topic, peer: PeerId) { + val peers = byTopic[topic] ?: return + peers.remove(peer) + if (peers.isEmpty()) byTopic.remove(topic) + } + + fun removeTopic(topic: Topic) { + byTopic.remove(topic) + } + + fun onPeerDisconnected(peer: PeerId) { + val emptied = mutableListOf() + for ((topic, peers) in byTopic) { + peers.remove(peer) + if (peers.isEmpty()) emptied += topic + } + emptied.forEach { byTopic.remove(it) } + } + + fun peerFlags(topic: Topic, peer: PeerId): PartialSubFlags = + byTopic[topic]?.get(peer) ?: PartialSubFlags.NONE + + fun peerRequestsPartial(topic: Topic, peer: PeerId) = + peerFlags(topic, peer).requestsPartial + + fun peerSupportsSendingPartial(topic: Topic, peer: PeerId) = + peerFlags(topic, peer).supportsSendingPartial + + internal fun snapshot(): Map> = + byTopic.mapValues { (_, v) -> v.toMap() } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt index 214d4b06d..69af91d5a 100644 --- a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/builders/GossipRouterBuilder.kt @@ -5,6 +5,7 @@ import io.libp2p.core.pubsub.ValidationResult import io.libp2p.etc.types.lazyVar import io.libp2p.pubsub.* import io.libp2p.pubsub.gossip.* +import io.libp2p.pubsub.gossip.partialmessages.* import java.util.* import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService @@ -40,6 +41,13 @@ open class GossipRouterBuilder( }, val gossipRouterEventListeners: MutableList = mutableListOf(), val enabledGossipExtensions: List = mutableListOf(), + + /** + * Client-supplied handler for the partial-messages extension. + * Required when [GossipExtension.PARTIAL_MESSAGES] is enabled; a build-time + * error is thrown if the extension is enabled without a handler. + */ + var partialMessagesHandler: PartialMessagesHandler<*>? = null, ) { var seenCache: SeenCache> by lazyVar { TTLSeenCache(SimpleSeenCache(), params.seenTTL, currentTimeSupplier) } @@ -76,13 +84,30 @@ open class GossipRouterBuilder( return router } + @Suppress("UNCHECKED_CAST") + private fun buildPartialMessagesAdapter(): PartialMessagesAdapter? { + val handler = partialMessagesHandler ?: return null + return PartialMessagesAdapterImpl( + handler = handler as PartialMessagesHandler, + stateStore = PartialGroupStateStore(), + feedback = NopPartialMessagesFeedback, + ) + } + open fun build(): GossipRouter { if (disposed) throw RuntimeException("The builder was already used") disposed = true - return createGossipRouter() + if (enabledGossipExtensions.contains(GossipExtension.PARTIAL_MESSAGES) && partialMessagesHandler == null) { + throw IllegalStateException( + "GossipExtension.PARTIAL_MESSAGES is enabled but no partialMessagesHandler was provided" + ) + } + val router = createGossipRouter() + router.partialMessages = buildPartialMessagesAdapter() + return router } - private fun buildGossipExtensionsConfig(): GossipExtensionsConfig { + protected fun buildGossipExtensionsConfig(): GossipExtensionsConfig { return GossipExtensionsConfig( partialMessagesEnabled = enabledGossipExtensions.contains(GossipExtension.PARTIAL_MESSAGES), testExtensionEnabled = enabledGossipExtensions.contains(GossipExtension.TEST_EXTENSION) diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialGroupStateStore.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialGroupStateStore.kt new file mode 100644 index 000000000..e34334109 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialGroupStateStore.kt @@ -0,0 +1,174 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger(PartialGroupStateStore::class.java) + +const val DEFAULT_GROUP_TTL_HEARTBEATS = 5 +const val DEFAULT_PEER_INITIATED_GROUP_LIMIT_PER_TOPIC = 255 +const val DEFAULT_PEER_INITIATED_GROUP_LIMIT_PER_TOPIC_PER_PEER = 8 + +/** + * Stable, value-based identity for a partial-messages group ID. + * + * Wraps a raw [ByteArray] so it can be used as a [HashMap] key with + * content equality rather than reference equality. + */ +class GroupId(val bytes: ByteArray) { + override fun equals(other: Any?): Boolean = + other is GroupId && bytes.contentEquals(other.bytes) + override fun hashCode(): Int = bytes.contentHashCode() + override fun toString(): String = bytes.joinToString("") { "%02x".format(it) } +} + +fun ByteArray.toGroupId(): GroupId = GroupId(this) + +/** + * Per-(topic, groupId) state container. + * + * [peerStates] is mutable and updated as parts arrive. + * [ttlInHeartbeats] is decremented each heartbeat and reset on [PartialGroupStateStore.resetTtl]. + * [initiatingPeer] is non-null iff [peerInitiated] is true. + * + * NOT thread-safe: accessed only on the pubsub event loop. + */ +class GroupState( + var ttlInHeartbeats: Int, + val peerInitiated: Boolean, + val initiatingPeer: PeerId? +) { + val peerStates: MutableMap = mutableMapOf() +} + +/** + * Stores and manages per-(topic, groupId) [GroupState] entries for the partial-messages + * extension. + * + * DoS caps (matching go-libp2p defaults): + * - [peerInitiatedGroupLimitPerTopic]: max peer-initiated groups across all peers per topic. + * - [peerInitiatedGroupLimitPerTopicPerPeer]: max peer-initiated groups per (topic, peer). + * + * NOT thread-safe: all access must be serialised on the pubsub event loop. + */ +class PartialGroupStateStore( + val groupTtlHeartbeats: Int = DEFAULT_GROUP_TTL_HEARTBEATS, + val peerInitiatedGroupLimitPerTopic: Int = DEFAULT_PEER_INITIATED_GROUP_LIMIT_PER_TOPIC, + val peerInitiatedGroupLimitPerTopicPerPeer: Int = DEFAULT_PEER_INITIATED_GROUP_LIMIT_PER_TOPIC_PER_PEER +) { + private val groups: HashMap>> = hashMapOf() + + fun getGroup(topic: Topic, groupId: GroupId): GroupState? = + groups[topic]?.get(groupId) + + /** + * Returns the group for (topic, groupId), creating it as a locally-initiated group + * if absent. Resets the TTL if the group already exists. + */ + fun getOrCreateLocalGroup(topic: Topic, groupId: GroupId): GroupState { + val topicGroups = groups.getOrPut(topic) { hashMapOf() } + val existing = topicGroups[groupId] + if (existing != null) { + existing.ttlInHeartbeats = groupTtlHeartbeats + return existing + } + return GroupState( + ttlInHeartbeats = groupTtlHeartbeats, + peerInitiated = false, + initiatingPeer = null + ).also { topicGroups[groupId] = it } + } + + /** + * Returns the group for (topic, groupId), creating it as a peer-initiated group if absent. + * Returns null and drops the RPC if either DoS cap would be exceeded. + */ + fun getOrCreatePeerGroup(topic: Topic, groupId: GroupId, peer: PeerId): GroupState? { + val topicGroups = groups.getOrPut(topic) { hashMapOf() } + val existing = topicGroups[groupId] + if (existing != null) return existing + + val totalPeerInitiated = topicGroups.values.count { it.peerInitiated } + if (totalPeerInitiated >= peerInitiatedGroupLimitPerTopic) { + logger.debug( + "Dropping peer-initiated group {} from {}: per-topic cap {} reached for topic {}", + groupId, + peer, + peerInitiatedGroupLimitPerTopic, + topic + ) + return null + } + + val peerTotal = topicGroups.values.count { it.initiatingPeer == peer } + if (peerTotal >= peerInitiatedGroupLimitPerTopicPerPeer) { + logger.debug( + "Dropping peer-initiated group {} from {}: per-peer cap {} reached for topic {}", + groupId, + peer, + peerInitiatedGroupLimitPerTopicPerPeer, + topic + ) + return null + } + + return GroupState( + ttlInHeartbeats = groupTtlHeartbeats, + peerInitiated = true, + initiatingPeer = peer + ).also { topicGroups[groupId] = it } + } + + /** Resets the TTL for (topic, groupId). Called by publishPartial. */ + fun resetTtl(topic: Topic, groupId: GroupId) { + groups[topic]?.get(groupId)?.let { it.ttlInHeartbeats = groupTtlHeartbeats } + } + + /** Returns a read-only snapshot of all groups for [topic]. */ + fun groupsForTopic(topic: Topic): Map> = + groups[topic] ?: emptyMap() + + /** + * Decrements TTLs and garbage-collects expired groups (TTL ≤ 0) and + * groups whose peerStates map has become empty. + */ + fun onHeartbeat() { + val topicIter = groups.entries.iterator() + while (topicIter.hasNext()) { + val (_, topicGroups) = topicIter.next() + val groupIter = topicGroups.entries.iterator() + while (groupIter.hasNext()) { + val (_, group) = groupIter.next() + group.ttlInHeartbeats-- + if (group.ttlInHeartbeats <= 0 || group.peerStates.isEmpty()) { + groupIter.remove() + } + } + if (topicGroups.isEmpty()) topicIter.remove() + } + } + + /** + * Removes [peer] from all group peerStates; garbage-collects groups that + * become empty as a result. + */ + fun onPeerDisconnected(peer: PeerId) { + val topicIter = groups.entries.iterator() + while (topicIter.hasNext()) { + val (_, topicGroups) = topicIter.next() + val groupIter = topicGroups.entries.iterator() + while (groupIter.hasNext()) { + val (_, group) = groupIter.next() + group.peerStates.remove(peer) + if (group.peerStates.isEmpty()) groupIter.remove() + } + if (topicGroups.isEmpty()) topicIter.remove() + } + } + + /** Drops all group state for [topic] (called when we unsubscribe). */ + fun onTopicUnsubscribed(topic: Topic) { + groups.remove(topic) + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesAdapter.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesAdapter.kt new file mode 100644 index 000000000..4cbd39b13 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesAdapter.kt @@ -0,0 +1,105 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic +import org.slf4j.LoggerFactory +import pubsub.pb.Rpc + +private val logger = LoggerFactory.getLogger(PartialMessagesAdapterImpl::class.java) + +/** + * Type-erased view of the partial-messages subsystem used by [io.libp2p.pubsub.gossip.GossipRouter]. + * + * All methods are called on the pubsub event thread. + */ +internal interface PartialMessagesAdapter { + fun onPeerDisconnected(peer: PeerId) + fun onTopicUnsubscribed(topic: Topic) + fun onHeartbeat() + fun onIncomingRpc(topic: Topic, from: PeerId, rpc: Rpc.PartialMessagesExtension) + + /** + * Called from [io.libp2p.pubsub.gossip.GossipRouter.emitGossip] for each topic where we + * support sending partial and at least one gossip candidate requested partial. + * + * Iterates all locally-initiated groups under [topic] and invokes + * [PartialMessagesHandler.onEmitGossip] once per group with [partialPeers] as the + * gossip-target set. No-op if [partialPeers] is empty or there are no locally-initiated groups. + */ + fun onEmitGossip(topic: Topic, partialPeers: Collection) + + /** + * Executes the client's [PublishActionsFn], updates group state, and enqueues + * outbound [Rpc.PartialMessagesExtension] RPCs via [enqueueFn]. + * + * [peerRequestsPartial] is used to enforce the spec MUST: omit [PublishAction.partialMessage] + * when the peer supports but did not request partial messages. + */ + fun publishPartial( + topic: Topic, + groupId: GroupId, + actionsFn: PublishActionsFn<*>, + peerRequestsPartial: (PeerId) -> Boolean, + enqueueFn: (PeerId, ByteArray?, ByteArray?) -> Unit + ) +} + +/** + * Bridges [GossipRouter] (which has no [PeerState] type parameter) to the typed + * [PartialMessagesHandler] and [PartialGroupStateStore]. + * + * Created once in [io.libp2p.pubsub.gossip.builders.GossipRouterBuilder] with an + * unchecked cast that is safe because [PeerState] is captured and used consistently + * throughout the lifetime of this object. + */ +internal class PartialMessagesAdapterImpl( + val handler: PartialMessagesHandler, + val stateStore: PartialGroupStateStore, + val feedback: PartialMessagesPeerFeedback +) : PartialMessagesAdapter { + + override fun onPeerDisconnected(peer: PeerId) = stateStore.onPeerDisconnected(peer) + override fun onTopicUnsubscribed(topic: Topic) = stateStore.onTopicUnsubscribed(topic) + override fun onHeartbeat() = stateStore.onHeartbeat() + + override fun onIncomingRpc(topic: Topic, from: PeerId, rpc: Rpc.PartialMessagesExtension) { + val groupId = rpc.groupID.toByteArray().toGroupId() + val groupState = stateStore.getOrCreatePeerGroup(topic, groupId, from) ?: return + handler.onIncomingRpc(from, groupState.peerStates, rpc, feedback) + } + + override fun onEmitGossip(topic: Topic, partialPeers: Collection) { + if (partialPeers.isEmpty()) return + for ((groupId, groupState) in stateStore.groupsForTopic(topic)) { + if (!groupState.peerInitiated) { + handler.onEmitGossip(topic, groupId.bytes, partialPeers, groupState.peerStates, feedback) + } + } + } + + @Suppress("UNCHECKED_CAST") + override fun publishPartial( + topic: Topic, + groupId: GroupId, + actionsFn: PublishActionsFn<*>, + peerRequestsPartial: (PeerId) -> Boolean, + enqueueFn: (PeerId, ByteArray?, ByteArray?) -> Unit + ) { + val typedFn = actionsFn as PublishActionsFn + val groupState = stateStore.getOrCreateLocalGroup(topic, groupId) + for ((peerId, action) in typedFn.decide(groupState.peerStates, peerRequestsPartial)) { + if (action.error != null) { + logger.debug("Skipping partial publish to {}: {}", peerId, action.error.message) + continue + } + // Spec MUST: omit partialMessage if peer supports but didn't request + val effectivePartialMessage = if (peerRequestsPartial(peerId)) action.partialMessage else null + if (effectivePartialMessage != null || action.partsMetadata != null) { + enqueueFn(peerId, effectivePartialMessage, action.partsMetadata) + } + if (action.nextPeerState != null) { + groupState.peerStates[peerId] = action.nextPeerState + } + } + } +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesHandler.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesHandler.kt new file mode 100644 index 000000000..e210c4d9a --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesHandler.kt @@ -0,0 +1,51 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic +import pubsub.pb.Rpc + +/** + * Client-supplied handler for the partial-messages extension. + * + * Both callbacks run on the pubsub event thread and MUST be fast and non-blocking. + * Dispatch heavy work (decoding, KZG validation) to a separate executor. + * + * @param PeerState opaque per-(topic, groupId, peerId) state that the library + * stores and passes back; the library never interprets it. + */ +interface PartialMessagesHandler { + + /** + * Called on every inbound [Rpc.PartialMessagesExtension] RPC. + * + * Any of [rpc].partialMessage and [rpc].partsMetadata may be absent; all + * four combinations are valid wire messages. + * + * [peerStates] reflects the current state for this (topic, groupId) pair across + * all peers. The map is a live view — do not retain a reference outside this call. + */ + fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback + ) + + /** + * Called once per locally-initiated group during the gossipsub heartbeat for + * gossip targets that are partial-capable on [topic]. + * + * The client typically responds by calling [io.libp2p.pubsub.gossip.Gossip.publishPartial] + * for the same (topic, groupId). + * + * [peerStates] reflects the current state for this group across all peers. + * The map is a live view — do not retain a reference outside this call. + */ + fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback + ) +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesPeerFeedback.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesPeerFeedback.kt new file mode 100644 index 000000000..5c3e4caff --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesPeerFeedback.kt @@ -0,0 +1,14 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic + +enum class FeedbackKind { USEFUL, INVALID, IGNORED } + +interface PartialMessagesPeerFeedback { + fun reportFeedback(topic: Topic, peer: PeerId, kind: FeedbackKind) +} + +internal object NopPartialMessagesFeedback : PartialMessagesPeerFeedback { + override fun reportFeedback(topic: Topic, peer: PeerId, kind: FeedbackKind) {} +} diff --git a/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PublishActions.kt b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PublishActions.kt new file mode 100644 index 000000000..c0b54c1a1 --- /dev/null +++ b/libp2p/src/main/kotlin/io/libp2p/pubsub/gossip/partialmessages/PublishActions.kt @@ -0,0 +1,32 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import io.libp2p.core.PeerId + +/** + * Encodes what the library should send to one peer for a single + * [io.libp2p.pubsub.gossip.Gossip.publishPartial] call. + * + * [nextPeerState] is applied atomically by the library per peer after the + * send; null means "leave the existing state unchanged". + */ +data class PublishAction( + val partialMessage: ByteArray? = null, + val partsMetadata: ByteArray? = null, + val nextPeerState: PeerState? = null, + val error: Throwable? = null +) + +/** + * Decision function supplied by the client to [io.libp2p.pubsub.gossip.Gossip.publishPartial]. + * + * [decide] is called on the pubsub event thread with the current peer state map + * and a predicate for checking whether a peer requested partial for the topic. + * It must return a sequence of (peerId, action) pairs — one per peer that + * should receive an outbound [pubsub.pb.Rpc.PartialMessagesExtension] RPC. + */ +fun interface PublishActionsFn { + fun decide( + peerStates: Map, + peerRequestsPartial: (PeerId) -> Boolean + ): Sequence>> +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt index 224b5a7ae..f0f6135dd 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipRouterBuilderTest.kt @@ -1,11 +1,21 @@ package io.libp2p.pubsub.gossip +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback import org.assertj.core.api.Assertions.assertThat import org.junit.jupiter.api.Test +import pubsub.pb.Rpc class GossipRouterBuilderTest { + private val nopHandler: PartialMessagesHandler = object : PartialMessagesHandler { + override fun onIncomingRpc(from: PeerId, peerStates: Map, rpc: Rpc.PartialMessagesExtension, feedback: PartialMessagesPeerFeedback) {} + override fun onEmitGossip(topic: Topic, groupId: ByteArray, gossipPeers: Collection, peerStates: Map, feedback: PartialMessagesPeerFeedback) {} + } + @Test fun `builds GossipRouter with both extensions disabled by default`() { val router = GossipRouterBuilder().build() @@ -36,6 +46,7 @@ class GossipRouterBuilderTest { GossipExtension.TEST_EXTENSION, GossipExtension.PARTIAL_MESSAGES, ) + .apply { partialMessagesHandler = nopHandler } .build() val localSupport = router.gossipExtensionsState.localExtensionSupport diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt index 1917310e9..09986878d 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/GossipTestsBase.kt @@ -8,6 +8,8 @@ import io.libp2p.pubsub.* import io.libp2p.pubsub.DeterministicFuzz.Companion.createGossipFuzzRouterFactory import io.libp2p.pubsub.DeterministicFuzz.Companion.createMockFuzzRouterFactory import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback import io.netty.handler.logging.LogLevel import pubsub.pb.Rpc @@ -15,6 +17,28 @@ abstract class GossipTestsBase { protected val GossipScore.testPeerScores get() = (this as DefaultGossipScore).peerScores + /** + * No-op [PartialMessagesHandler] for use in tests that enable the partial-messages + * extension but don't exercise handler behaviour. + */ + protected val nopPartialMessagesHandler: PartialMessagesHandler = + object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: pubsub.pb.Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback + ) {} + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback + ) {} + } + protected fun newProtoMessage(topic: Topic, seqNo: Long, data: ByteArray) = Rpc.Message.newBuilder() .addTopicIDs(topic) @@ -31,10 +55,20 @@ abstract class GossipTestsBase { val mockRouterCount: Int = 10, val params: GossipParams = GossipParams(), val scoreParams: GossipScoreParams = GossipScoreParams(), - val protocol: PubsubProtocol = PubsubProtocol.Gossip_V_1_1 + val protocol: PubsubProtocol = PubsubProtocol.Gossip_V_1_1, + val enabledGossipExtensions: List = listOf(), + val partialMessagesHandler: PartialMessagesHandler<*>? = null, ) { val fuzz = DeterministicFuzz() - val gossipRouterBuilderFactory = { GossipRouterBuilder(protocol = protocol, params = params, scoreParams = scoreParams) } + val gossipRouterBuilderFactory = { + GossipRouterBuilder( + protocol = protocol, + params = params, + scoreParams = scoreParams, + enabledGossipExtensions = enabledGossipExtensions, + partialMessagesHandler = partialMessagesHandler, + ) + } val router0 = fuzz.createTestRouter(createGossipFuzzRouterFactory(gossipRouterBuilderFactory)) val routers = (0 until mockRouterCount).map { fuzz.createTestRouter(createMockFuzzRouterFactory()) } val connections = mutableListOf() @@ -63,8 +97,8 @@ abstract class GossipTestsBase { val scoreParams: GossipScoreParams = GossipScoreParams(), val mockRouterFactory: DeterministicFuzzRouterFactory = createMockFuzzRouterFactory(), val protocol: PubsubProtocol = PubsubProtocol.Gossip_V_1_1, - val enabledGossipExtensions: List = listOf(GossipExtension.TEST_EXTENSION) - + val enabledGossipExtensions: List = listOf(GossipExtension.TEST_EXTENSION), + val partialMessagesHandler: PartialMessagesHandler<*>? = null, ) { val fuzz = DeterministicFuzz() val gossipRouterBuilderFactory = { @@ -72,7 +106,8 @@ abstract class GossipTestsBase { protocol = protocol, params = coreParams, scoreParams = scoreParams, - enabledGossipExtensions = enabledGossipExtensions + enabledGossipExtensions = enabledGossipExtensions, + partialMessagesHandler = partialMessagesHandler, ) } val router1 = fuzz.createTestRouter(createGossipFuzzRouterFactory(gossipRouterBuilderFactory)) diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/PartialSubscriptionStateTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/PartialSubscriptionStateTest.kt new file mode 100644 index 000000000..60a05c2ac --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/PartialSubscriptionStateTest.kt @@ -0,0 +1,153 @@ +package io.libp2p.pubsub.gossip + +import io.libp2p.core.PeerId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PartialSubscriptionStateTest { + + private lateinit var state: PartialSubscriptionState + private lateinit var peer1: PeerId + private lateinit var peer2: PeerId + private lateinit var peer3: PeerId + + private val topicA = "topic-a" + private val topicB = "topic-b" + + @BeforeEach + fun setup() { + state = PartialSubscriptionState() + peer1 = PeerId.random() + peer2 = PeerId.random() + peer3 = PeerId.random() + } + + @Test + fun `unknown peer returns NONE`() { + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.peerRequestsPartial(topicA, peer1)).isFalse() + assertThat(state.peerSupportsSendingPartial(topicA, peer1)).isFalse() + } + + @Test + fun `setPeerFlags stores and peerFlags reads back`() { + val flags = PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + state.setPeerFlags(topicA, peer1, flags) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(flags) + assertThat(state.peerRequestsPartial(topicA, peer1)).isTrue() + assertThat(state.peerSupportsSendingPartial(topicA, peer1)).isTrue() + } + + @Test + fun `setPeerFlags with NONE removes entry`() { + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = false)) + state.setPeerFlags(topicA, peer1, PartialSubFlags.NONE) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.snapshot()).doesNotContainKey(topicA) + } + + @Test + fun `setPeerFlags overwrites previous flags for same peer and topic`() { + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = false, supportsSendingPartial = true)) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo( + PartialSubFlags(requestsPartial = false, supportsSendingPartial = true) + ) + } + + @Test + fun `removePeerFlags drops the peer's entry and GCs empty topic`() { + val flags = PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + state.setPeerFlags(topicA, peer1, flags) + + state.removePeerFlags(topicA, peer1) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.snapshot()).doesNotContainKey(topicA) + } + + @Test + fun `removePeerFlags on unknown peer or topic is a no-op`() { + state.removePeerFlags(topicA, peer1) // nothing stored yet + assertThat(state.snapshot()).isEmpty() + + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + state.removePeerFlags(topicB, peer1) // topic mismatch + state.removePeerFlags(topicA, peer2) // peer mismatch + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo( + PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + ) + } + + @Test + fun `removeTopic drops all peers for that topic, leaves other topics intact`() { + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + state.setPeerFlags(topicA, peer2, PartialSubFlags(requestsPartial = false, supportsSendingPartial = true)) + state.setPeerFlags(topicB, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + state.removeTopic(topicA) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.peerFlags(topicA, peer2)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.peerFlags(topicB, peer1)).isEqualTo( + PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + ) + } + + @Test + fun `onPeerDisconnected clears the peer across all topics, leaves other peers intact`() { + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + state.setPeerFlags(topicA, peer2, PartialSubFlags(requestsPartial = false, supportsSendingPartial = true)) + state.setPeerFlags(topicB, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + state.setPeerFlags(topicB, peer3, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + state.onPeerDisconnected(peer1) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.peerFlags(topicB, peer1)).isEqualTo(PartialSubFlags.NONE) + assertThat(state.peerFlags(topicA, peer2)).isEqualTo( + PartialSubFlags(requestsPartial = false, supportsSendingPartial = true) + ) + assertThat(state.peerFlags(topicB, peer3)).isEqualTo( + PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + ) + } + + @Test + fun `onPeerDisconnected GCs topics that become empty`() { + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + state.setPeerFlags(topicB, peer2, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + state.onPeerDisconnected(peer1) + + assertThat(state.snapshot()).doesNotContainKey(topicA) + assertThat(state.snapshot()).containsKey(topicB) + } + + @Test + fun `onPeerDisconnected on unknown peer is a no-op`() { + state.setPeerFlags(topicA, peer1, PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + state.onPeerDisconnected(peer2) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo( + PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + ) + } + + @Test + fun `peer independence on same topic`() { + val flags1 = PartialSubFlags(requestsPartial = true, supportsSendingPartial = true) + val flags2 = PartialSubFlags(requestsPartial = false, supportsSendingPartial = true) + state.setPeerFlags(topicA, peer1, flags1) + state.setPeerFlags(topicA, peer2, flags2) + + assertThat(state.peerFlags(topicA, peer1)).isEqualTo(flags1) + assertThat(state.peerFlags(topicA, peer2)).isEqualTo(flags2) + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt index c39f4d51b..0a78acd85 100644 --- a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/GossipExtensionsMessageHandlingTest.kt @@ -138,7 +138,8 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { enabledGossipExtensions = listOf( GossipExtension.TEST_EXTENSION, GossipExtension.PARTIAL_MESSAGES - ) + ), + partialMessagesHandler = nopPartialMessagesHandler, ) val receivedMessage = test.mockRouter.waitForMessage( @@ -198,6 +199,7 @@ class GossipExtensionsMessageHandlingTest : GossipTestsBase() { val test = TwoRoutersTest( protocol = PubsubProtocol.Gossip_V_1_3, enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, // Creating GossipScoreParams with behaviourPenaltyWeight (peer bad behavior affecting // score). Here we are not interested if the weight is "correct". What we want to see if // that a peer is penalized for sending more than one ControlExtensions message. diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesEmitGossipTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesEmitGossipTest.kt new file mode 100644 index 000000000..f411af7ba --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesEmitGossipTest.kt @@ -0,0 +1,316 @@ +package io.libp2p.pubsub.gossip.extensions + +import io.libp2p.core.PeerId +import io.libp2p.etc.types.seconds +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.Topic +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipParams +import io.libp2p.pubsub.gossip.GossipTestsBase +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +/** + * Tests for Step 9 — IHAVE replacement with onEmitGossip (§5.3). + * + * When we support sending partial messages for a topic and a peer has requested + * partial delivery, the gossip router MUST NOT send IHAVE to that peer during the + * heartbeat lazy-push. Instead, the router calls handler.onEmitGossip once per + * locally-initiated group under that topic. + */ +class PartialMessagesEmitGossipTest : GossipTestsBase() { + + private val topicId = "test-topic" + private val groupIdBytes = byteArrayOf(1, 2, 3) + + // D=0 keeps all mock routers out of the mesh, making them IHAVE gossip candidates. + // DLazy=6 ensures all eligible peers are selected in the lazy-push. + // floodPublishMaxMessageSizeThreshold=0 disables flood publish so IHAVE is the + // sole mechanism for notifying non-mesh peers about messages. + private val testParams = GossipParams( + D = 0, + DLow = 0, + DHigh = 0, + DLazy = 6, + floodPublishMaxMessageSizeThreshold = 0, + ) + + private fun controlExtensionsWithPartial(): Rpc.RPC = + Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions( + Rpc.ControlExtensions.newBuilder().setPartialMessages(true) + ) + ).build() + + private fun subscribeRpc( + requestsPartial: Boolean, + supportsSendingPartial: Boolean, + ): Rpc.RPC = + Rpc.RPC.newBuilder().addSubscriptions( + Rpc.RPC.SubOpts.newBuilder() + .setTopicid(topicId) + .setSubscribe(true) + .setRequestsPartial(requestsPartial) + .setSupportsSendingPartial(supportsSendingPartial) + ).build() + + /** + * Creates a 2-mock-router test network where: + * - mockRouters[0] is a plain non-partial peer + * - mockRouters[1] is a partial-capable peer that has requested partial delivery + * - gossipRouter supports sending partial for [topicId] + * - D=0 so no mesh, all peers are lazy-push (IHAVE) candidates + */ + private fun startNetwork( + handler: PartialMessagesHandler<*> = nopPartialMessagesHandler, + ): ManyRoutersTest { + val test = ManyRoutersTest( + mockRouterCount = 2, + protocol = PubsubProtocol.Gossip_V_1_3, + params = testParams, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = handler, + ) + test.connectAll() + test.mockRouters.forEach { it.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + test.gossipRouter.setTopicPartialFlags( + topicId, + requestsPartial = false, + supportsSendingPartial = true, + ) + + // mockRouters[1] is the partial peer: node-level support + topic-level request. + test.mockRouters[1].sendToSingle(controlExtensionsWithPartial()) + test.mockRouters[1].sendToSingle(subscribeRpc(requestsPartial = true, supportsSendingPartial = true)) + + test.gossipRouter.submitOnEventThread {}.join() + return test + } + + // ── IHAVE suppression ──────────────────────────────────────────────────── + + @Test + fun `IHAVE not sent to partial peer when we support sending partial`() { + val test = startNetwork() + + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + test.fuzz.timeController.addTime(2.seconds) + + // Non-partial peer MUST receive IHAVE. + val ihaveToNormal = test.mockRouters[0].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.ihaveList } + assertThat(ihaveToNormal).isNotEmpty() + + // Partial peer MUST NOT receive IHAVE. + val ihaveToPartial = test.mockRouters[1].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.ihaveList } + assertThat(ihaveToPartial).isEmpty() + } + + @Test + fun `IHAVE still sent to partial peer when partial extension disabled`() { + val test = ManyRoutersTest( + mockRouterCount = 1, + protocol = PubsubProtocol.Gossip_V_1_3, + params = testParams, + ) + test.connectAll() + test.mockRouters[0].subscribe(topicId) + test.gossipRouter.subscribe(topicId) + test.gossipRouter.submitOnEventThread {}.join() + + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + test.fuzz.timeController.addTime(2.seconds) + + val ihaves = test.mockRouters[0].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.ihaveList } + assertThat(ihaves).isNotEmpty() + } + + @Test + fun `IHAVE still sent to partial peer when we do not support sending partial for the topic`() { + val test = ManyRoutersTest( + mockRouterCount = 2, + protocol = PubsubProtocol.Gossip_V_1_3, + params = testParams, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + test.connectAll() + test.mockRouters.forEach { it.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + // We do NOT call setTopicPartialFlags — supportsSendingPartial stays false. + test.mockRouters[1].sendToSingle(controlExtensionsWithPartial()) + test.mockRouters[1].sendToSingle(subscribeRpc(requestsPartial = true, supportsSendingPartial = true)) + test.gossipRouter.submitOnEventThread {}.join() + + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + test.fuzz.timeController.addTime(2.seconds) + + // IHAVE IS sent because we don't support sending partial for this topic. + val ihaveToPartialPeer = test.mockRouters[1].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.ihaveList } + assertThat(ihaveToPartialPeer).isNotEmpty() + } + + @Test + fun `IHAVE still sent when peer supports partial but did not request it`() { + val test = ManyRoutersTest( + mockRouterCount = 2, + protocol = PubsubProtocol.Gossip_V_1_3, + params = testParams, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + test.connectAll() + test.mockRouters.forEach { it.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + test.gossipRouter.setTopicPartialFlags( + topicId, + requestsPartial = false, + supportsSendingPartial = true, + ) + // mockRouters[1] supports sending partial but did NOT request it. + test.mockRouters[1].sendToSingle(controlExtensionsWithPartial()) + test.mockRouters[1].sendToSingle(subscribeRpc(requestsPartial = false, supportsSendingPartial = true)) + test.gossipRouter.submitOnEventThread {}.join() + + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + test.fuzz.timeController.addTime(2.seconds) + + // IHAVE IS sent because the peer didn't request partial. + val ihaveToPartialPeer = test.mockRouters[1].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.ihaveList } + assertThat(ihaveToPartialPeer).isNotEmpty() + } + + // ── onEmitGossip callback ───────────────────────────────────────────────── + + @Test + fun `onEmitGossip called for locally-initiated group with partial-capable gossip peers`() { + data class Call(val topic: Topic, val groupId: ByteArray, val peers: List) + val calls = mutableListOf() + + val handler = object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) {} + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) { + calls += Call(topic, groupId, gossipPeers.toList()) + } + } + + val test = startNetwork(handler) + val partialPeerId = test.routers[1].peerId + + // Create a locally-initiated group via publishPartial. + test.gossipRouter.publishPartial(topicId, groupIdBytes, PublishActionsFn { _, _ -> emptySequence() }) + + // Publish a message to put an entry in the mcache; emitGossip only runs when + // mcache is non-empty. + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + + test.fuzz.timeController.addTime(2.seconds) + + assertThat(calls).isNotEmpty() + val call = calls.first { it.topic == topicId && it.groupId.contentEquals(groupIdBytes) } + assertThat(call.peers).contains(partialPeerId) + } + + @Test + fun `onEmitGossip not called for peer-initiated groups`() { + data class Call(val topic: Topic, val groupId: ByteArray) + val calls = mutableListOf() + + val handler = object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) {} + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) { + calls += Call(topic, groupId) + } + } + + val test = startNetwork(handler) + val peerGroupId = byteArrayOf(9, 8, 7) + + // Simulate an inbound partial RPC that creates a peer-initiated group. + val inboundRpc = Rpc.RPC.newBuilder().setPartial( + Rpc.PartialMessagesExtension.newBuilder() + .setTopicID(topicId) + .setGroupID(com.google.protobuf.ByteString.copyFrom(peerGroupId)) + .setPartsMetadata(com.google.protobuf.ByteString.copyFrom(byteArrayOf(0x01))) + ).build() + test.mockRouters[1].sendToSingle(inboundRpc) + test.gossipRouter.submitOnEventThread {}.join() + + // There are no locally-initiated groups; only a peer-initiated one. + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + test.fuzz.timeController.addTime(2.seconds) + + // handler.onEmitGossip MUST NOT be called for the peer-initiated group. + assertThat(calls.filter { it.groupId.contentEquals(peerGroupId) }).isEmpty() + } + + @Test + fun `onEmitGossip not called when there are no locally-initiated groups`() { + val calls = mutableListOf() + + val handler = object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) {} + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) { + calls += Unit + } + } + + val test = startNetwork(handler) + + // No publishPartial call → no locally-initiated groups exist. + test.gossipRouter.publish(newMessage(topicId, 0L, "data".toByteArray())) + test.fuzz.timeController.addTime(2.seconds) + + assertThat(calls).isEmpty() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesEndToEndTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesEndToEndTest.kt new file mode 100644 index 000000000..bb9b9d17d --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesEndToEndTest.kt @@ -0,0 +1,302 @@ +package io.libp2p.pubsub.gossip.extensions + +import com.google.protobuf.ByteString +import io.libp2p.core.PeerId +import io.libp2p.core.dsl.host +import io.libp2p.core.mux.StreamMuxerProtocol +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.Topic +import io.libp2p.pubsub.gossip.Gossip +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipRouter +import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback +import io.libp2p.pubsub.gossip.partialmessages.PublishAction +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn +import io.libp2p.security.noise.NoiseXXSecureChannel +import io.libp2p.transport.tcp.TcpTransport +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException + +/** + * End-to-end integration test for partial messages over real TCP / Noise / Mplex (Step 5). + * + * Two libp2p hosts — both with [GossipExtension.PARTIAL_MESSAGES] enabled and a + * trivial bitmap-based [PartialMessagesHandler] — connect, subscribe, and exchange + * partial-message RPCs. This exercises the complete stack that Steps 1-4 built: + * SubOpts flag plumbing, handler dispatch, group-state tracking, inbound dispatch, + * and outbound [Gossip.publishPartial]. + * + * PeerState is a single [ByteArray] where each bit represents one "part" + * (0 = not yet offered to peer, 1 = offered). + */ +class PartialMessagesEndToEndTest { + + private val topic = "test-topic" + private val groupId = "group-1".toByteArray() + + data class InboundCall( + val from: PeerId, + val rpc: Rpc.PartialMessagesExtension, + val peerStatesSnapshot: Map, + ) + + private val node1Inbound = CopyOnWriteArrayList() + private val node2Inbound = CopyOnWriteArrayList() + + private fun bitmapHandler(sink: CopyOnWriteArrayList): PartialMessagesHandler = + object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) { + sink += InboundCall(from, rpc, peerStates.mapValues { it.value.copyOf() }) + } + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) {} + } + + private fun buildRouter(handler: PartialMessagesHandler) = + GossipRouterBuilder( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = handler, + ).build() + + private val router1 by lazy { buildRouter(bitmapHandler(node1Inbound)) } + private val router2 by lazy { buildRouter(bitmapHandler(node2Inbound)) } + + private val gossip1 by lazy { Gossip(router1) } + private val gossip2 by lazy { Gossip(router2) } + + private val host1 by lazy { + host { + identity { random() } + transports { add(::TcpTransport) } + network { listen("/ip4/127.0.0.1/tcp/0") } + secureChannels { add(::NoiseXXSecureChannel) } + muxers { +StreamMuxerProtocol.Mplex } + protocols { +gossip1 } + } + } + + private val host2 by lazy { + host { + identity { random() } + transports { add(::TcpTransport) } + network { listen("/ip4/127.0.0.1/tcp/0") } + secureChannels { add(::NoiseXXSecureChannel) } + muxers { +StreamMuxerProtocol.Mplex } + protocols { +gossip2 } + } + } + + @BeforeEach + fun setUp() { + host1.start().get(5, TimeUnit.SECONDS) + host2.start().get(5, TimeUnit.SECONDS) + } + + @AfterEach + fun tearDown() { + host1.stop().get(5, TimeUnit.SECONDS) + host2.stop().get(5, TimeUnit.SECONDS) + } + + /** + * Connects the two hosts and subscribes both to [topic] with full partial flags. + * Returns after the ControlExtensions handshake and SubOpts exchange have both + * been processed on their respective event threads. + */ + private fun connectAndSubscribeWithPartialFlags() { + // Set flags before connecting so they are included in the SubOpts sent on peer activation. + router1.setTopicPartialFlags(topic, requestsPartial = true, supportsSendingPartial = true) + router2.setTopicPartialFlags(topic, requestsPartial = true, supportsSendingPartial = true) + router1.subscribe(topic) + router2.subscribe(topic) + + // listenAddresses() already includes /p2p/. + val host2Addr = host2.listenAddresses().first() + host1.network.connect(host2.peerId, host2Addr).get(10, TimeUnit.SECONDS) + + // Wait for ControlExtensions handshake (required before partial RPCs are accepted). + val peer1Id = host1.peerId + val peer2Id = host2.peerId + waitForOnEventThread(router1) { router1.gossipExtensionsState.peerSupportsPartialMessages(peer2Id) } + waitForOnEventThread(router2) { router2.gossipExtensionsState.peerSupportsPartialMessages(peer1Id) } + + // Wait for SubOpts with partial flags to be processed by both sides. + waitForOnEventThread(router1) { router1.partialSubscriptionState.peerRequestsPartial(topic, peer2Id) } + waitForOnEventThread(router2) { router2.partialSubscriptionState.peerRequestsPartial(topic, peer1Id) } + } + + private fun waitFor(predicate: () -> Boolean) { + repeat(100) { + if (predicate()) return + Thread.sleep(100) + } + throw TimeoutException("Timed out waiting for condition") + } + + private fun waitForOnEventThread(router: GossipRouter, predicate: () -> Boolean) { + waitFor { router.submitOnEventThread { predicate() }.get(1, TimeUnit.SECONDS) } + } + + @Test + fun `publishPartial delivers RPC with payload and metadata to peer handler`() { + connectAndSubscribeWithPartialFlags() + + val partPayload = byteArrayOf(0xDE.toByte(), 0xAD.toByte(), 0xBE.toByte(), 0xEF.toByte()) + val metaBitmap = byteArrayOf(0x01) // bit 0 set: has part 0 + val peer2Id = host2.peerId + + gossip1.publishPartial( + topic, + groupId, + PublishActionsFn { _, _ -> + sequenceOf(peer2Id to PublishAction(partialMessage = partPayload, partsMetadata = metaBitmap)) + }, + ).get(5, TimeUnit.SECONDS) + + waitFor { node2Inbound.isNotEmpty() } + + val call = node2Inbound.single() + assertThat(call.from).isEqualTo(host1.peerId) + assertThat(call.rpc.topicID).isEqualTo(topic) + assertThat(call.rpc.groupID).isEqualTo(ByteString.copyFrom(groupId)) + assertThat(call.rpc.partialMessage.toByteArray()).isEqualTo(partPayload) + assertThat(call.rpc.partsMetadata.toByteArray()).isEqualTo(metaBitmap) + } + + @Test + fun `bidirectional partial RPC round-trip between two hosts`() { + connectAndSubscribeWithPartialFlags() + + val peer1Id = host1.peerId + val peer2Id = host2.peerId + + val n1Payload = byteArrayOf(0xAA.toByte()) + val n1Meta = byteArrayOf(0x01) // node1 has part 0 + + gossip1.publishPartial( + topic, + groupId, + PublishActionsFn { _, _ -> + sequenceOf(peer2Id to PublishAction(partialMessage = n1Payload, partsMetadata = n1Meta)) + }, + ).get(5, TimeUnit.SECONDS) + + waitFor { node2Inbound.isNotEmpty() } + + val n2Payload = byteArrayOf(0xBB.toByte()) + val n2Meta = byteArrayOf(0x02) // node2 has part 1 + + gossip2.publishPartial( + topic, + groupId, + PublishActionsFn { _, _ -> + sequenceOf(peer1Id to PublishAction(partialMessage = n2Payload, partsMetadata = n2Meta)) + }, + ).get(5, TimeUnit.SECONDS) + + waitFor { node1Inbound.isNotEmpty() } + + val callToNode2 = node2Inbound.single() + assertThat(callToNode2.from).isEqualTo(peer1Id) + assertThat(callToNode2.rpc.topicID).isEqualTo(topic) + assertThat(callToNode2.rpc.groupID).isEqualTo(ByteString.copyFrom(groupId)) + assertThat(callToNode2.rpc.partialMessage.toByteArray()).isEqualTo(n1Payload) + assertThat(callToNode2.rpc.partsMetadata.toByteArray()).isEqualTo(n1Meta) + + val callToNode1 = node1Inbound.single() + assertThat(callToNode1.from).isEqualTo(peer2Id) + assertThat(callToNode1.rpc.topicID).isEqualTo(topic) + assertThat(callToNode1.rpc.groupID).isEqualTo(ByteString.copyFrom(groupId)) + assertThat(callToNode1.rpc.partialMessage.toByteArray()).isEqualTo(n2Payload) + assertThat(callToNode1.rpc.partsMetadata.toByteArray()).isEqualTo(n2Meta) + } + + @Test + fun `nextPeerState is persisted and passed to subsequent decide calls`() { + connectAndSubscribeWithPartialFlags() + + val peer2Id = host2.peerId + val statesSeenInSecondCall = CopyOnWriteArrayList() + + // First publish: store nextPeerState = 0x01 ("sent part 0 to peer2"). + gossip1.publishPartial( + topic, + groupId, + PublishActionsFn { _, _ -> + sequenceOf(peer2Id to PublishAction(partsMetadata = byteArrayOf(0x03), nextPeerState = byteArrayOf(0x01))) + }, + ).get(5, TimeUnit.SECONDS) + + // Second publish: decide() should observe peerStates[peer2Id] = 0x01. + gossip1.publishPartial( + topic, + groupId, + PublishActionsFn { peerStates, _ -> + statesSeenInSecondCall += peerStates[peer2Id]?.copyOf() + sequenceOf(peer2Id to PublishAction(partsMetadata = byteArrayOf(0x03), nextPeerState = byteArrayOf(0x03))) + }, + ).get(5, TimeUnit.SECONDS) + + assertThat(statesSeenInSecondCall).hasSize(1) + assertThat(statesSeenInSecondCall[0]).isEqualTo(byteArrayOf(0x01)) + } + + @Test + fun `spec MUST - partialMessage is omitted when peer supports sending but did not request`() { + // Node2 supports sending partial but does NOT request it from node1. + // The library MUST omit partialMessage in any RPC sent to node2. + router1.setTopicPartialFlags(topic, requestsPartial = true, supportsSendingPartial = true) + router2.setTopicPartialFlags(topic, requestsPartial = false, supportsSendingPartial = true) + router1.subscribe(topic) + router2.subscribe(topic) + + val host2Addr = host2.listenAddresses().first() + host1.network.connect(host2.peerId, host2Addr).get(10, TimeUnit.SECONDS) + + val peer2Id = host2.peerId + waitForOnEventThread(router1) { router1.gossipExtensionsState.peerSupportsPartialMessages(peer2Id) } + waitForOnEventThread(router2) { router2.gossipExtensionsState.peerSupportsPartialMessages(host1.peerId) } + // node2 subscribed with supportsSendingPartial=true, requestsPartial=false + waitForOnEventThread(router1) { router1.partialSubscriptionState.peerSupportsSendingPartial(topic, peer2Id) } + + gossip1.publishPartial( + topic, + groupId, + PublishActionsFn { _, _ -> + sequenceOf( + peer2Id to PublishAction( + partialMessage = byteArrayOf(0xFF.toByte()), + partsMetadata = byteArrayOf(0x01), + ), + ) + }, + ).get(5, TimeUnit.SECONDS) + + waitFor { node2Inbound.isNotEmpty() } + + // Library MUST have omitted partialMessage because node2 didn't request partial. + val call = node2Inbound.single() + assertThat(call.rpc.hasPartialMessage()).isFalse() + assertThat(call.rpc.partsMetadata.toByteArray()).isEqualTo(byteArrayOf(0x01)) + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesFullMsgSuppressionTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesFullMsgSuppressionTest.kt new file mode 100644 index 000000000..d1495f9e8 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesFullMsgSuppressionTest.kt @@ -0,0 +1,160 @@ +package io.libp2p.pubsub.gossip.extensions + +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipTestsBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +/** + * Tests for Step 6 — full-message suppression (§5.1). + * + * When a peer supports partial messages (ControlExtensions handshake) AND has + * requested partial delivery for a topic (SubOpts), the gossip router MUST NOT + * send the full message to that peer in either [broadcastOutbound] or + * [broadcastInbound]. The client is responsible for pushing parts via + * [Gossip.publishPartial]. + */ +class PartialMessagesFullMsgSuppressionTest : GossipTestsBase() { + + private val topicId = "test-topic" + + private fun newTest() = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + + private fun controlExtensionsWithPartial(): Rpc.RPC = + Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions( + Rpc.ControlExtensions.newBuilder().setPartialMessages(true) + ) + ).build() + + private fun subscribeRpc( + topic: String = topicId, + requestsPartial: Boolean, + supportsSendingPartial: Boolean, + ): Rpc.RPC = + Rpc.RPC.newBuilder().addSubscriptions( + Rpc.RPC.SubOpts.newBuilder() + .setTopicid(topic) + .setSubscribe(true) + .setRequestsPartial(requestsPartial) + .setSupportsSendingPartial(supportsSendingPartial) + ).build() + + private fun TwoRoutersTest.flushRouter() = + gossipRouter.submitOnEventThread {}.join() + + // ── broadcastOutbound ──────────────────────────────────────────────────── + + @Test + fun `broadcastOutbound - full message NOT sent to peer that requested partial`() { + val test = newTest() + + test.mockRouter.subscribe(topicId) + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(subscribeRpc(requestsPartial = true, supportsSendingPartial = true)) + test.flushRouter() + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.gossipRouter.publish(msg) + test.flushRouter() + + assertThat(test.mockRouter.inboundMessages.none { it.publishCount > 0 }).isTrue() + } + + @Test + fun `broadcastOutbound - full message still sent when partial extension disabled`() { + val test = TwoRoutersTest(protocol = PubsubProtocol.Gossip_V_1_3) + + test.mockRouter.subscribe(topicId) + test.flushRouter() + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.gossipRouter.publish(msg) + test.mockRouter.waitForMessage { it.publishCount > 0 } + } + + @Test + fun `broadcastOutbound - full message still sent when peer did not request partial`() { + val test = newTest() + + test.mockRouter.subscribe(topicId) + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + // No requestsPartial flag — peer supports sending but did not request + test.mockRouter.sendToSingle(subscribeRpc(requestsPartial = false, supportsSendingPartial = true)) + test.flushRouter() + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.gossipRouter.publish(msg) + test.mockRouter.waitForMessage { it.publishCount > 0 } + } + + @Test + fun `broadcastOutbound - full message still sent when peer supports partial at node level but no topic sub flag`() { + val test = newTest() + + test.mockRouter.subscribe(topicId) + // ControlExtensions: peer supports partial, but no SubOpts requestsPartial + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.flushRouter() + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.gossipRouter.publish(msg) + test.mockRouter.waitForMessage { it.publishCount > 0 } + } + + // ── broadcastInbound ───────────────────────────────────────────────────── + + @Test + fun `broadcastInbound - forwarded full message NOT sent to peer that requested partial`() { + val test = ManyRoutersTest( + mockRouterCount = 2, + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + test.connectAll() + + // Subscribe mock routers first so gossipRouter.subscribe grafts them immediately. + test.routers.forEach { it.router.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + + // mockRouters[1] announces partial support and requests partial for the topic. + test.mockRouters[1].sendToSingle(controlExtensionsWithPartial()) + test.mockRouters[1].sendToSingle(subscribeRpc(requestsPartial = true, supportsSendingPartial = true)) + + // mockRouters[0] sends a full message that gossipRouter would normally forward. + test.mockRouters[0].sendToSingle( + Rpc.RPC.newBuilder().addPublish(newProtoMessage(topicId, 0L, "Hello".toByteArray())).build() + ) + test.fuzz.timeController.addTime(100) + + assertThat(test.mockRouters[1].inboundMessages.none { it.publishCount > 0 }).isTrue() + } + + @Test + fun `broadcastInbound - forwarded full message IS sent to non-partial peer (sanity)`() { + val test = ManyRoutersTest( + mockRouterCount = 2, + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + test.connectAll() + + test.routers.forEach { it.router.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + + // mockRouters[1] does NOT request partial — should receive the forwarded message. + test.mockRouters[0].sendToSingle( + Rpc.RPC.newBuilder().addPublish(newProtoMessage(topicId, 0L, "Hello".toByteArray())).build() + ) + + test.mockRouters[1].waitForMessage { it.publishCount > 0 } + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesIDontWantSuppressionTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesIDontWantSuppressionTest.kt new file mode 100644 index 000000000..88351009e --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesIDontWantSuppressionTest.kt @@ -0,0 +1,174 @@ +package io.libp2p.pubsub.gossip.extensions + +import io.libp2p.etc.types.millis +import io.libp2p.etc.types.seconds +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipParams +import io.libp2p.pubsub.gossip.GossipTestsBase +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +/** + * Tests for Step 7 — IDONTWANT suppression (§5.2). + * + * When we have requested partial messages for topic T and a peer supports + * sending partial for T, the gossip router MUST NOT send IDONTWANT to that peer. + * Sending IDONTWANT would be redundant — the peer is expected to deliver partial + * RPCs instead of full messages. + */ +class PartialMessagesIDontWantSuppressionTest : GossipTestsBase() { + + private val topicId = "test-topic" + + private fun controlExtensionsWithPartial(): Rpc.RPC = + Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions( + Rpc.ControlExtensions.newBuilder().setPartialMessages(true) + ) + ).build() + + private fun subscribeRpc( + topic: String = topicId, + requestsPartial: Boolean, + supportsSendingPartial: Boolean, + ): Rpc.RPC = + Rpc.RPC.newBuilder().addSubscriptions( + Rpc.RPC.SubOpts.newBuilder() + .setTopicid(topic) + .setSubscribe(true) + .setRequestsPartial(requestsPartial) + .setSupportsSendingPartial(supportsSendingPartial) + ).build() + + /** + * Creates a 3-router test network (gossipRouter + 3 mock routers) with all + * peers grafted into the mesh. Uses Gossip_V_1_3 with the partial-messages + * extension enabled. mockRouters[0] acts as publisher; [1] and [2] are gossip + * recipients. + */ + private fun startNetwork(): ManyRoutersTest { + val test = ManyRoutersTest( + mockRouterCount = 3, + protocol = PubsubProtocol.Gossip_V_1_3, + params = GossipParams(iDontWantMinMessageSizeThreshold = 5), + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + test.connectAll() + + test.mockRouters.forEach { it.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + + // Two heartbeats to ensure all peers are GRAFTed into the mesh. + test.fuzz.timeController.addTime(2.seconds) + + return test + } + + @Test + fun `IDONTWANT not sent to peer that supports sending partial when we request partial`() { + val test = startNetwork() + + // We (gossipRouter) request partial for this topic. + test.gossipRouter.setTopicPartialFlags(topicId, requestsPartial = true, supportsSendingPartial = false) + + // mockRouters[2] (partial-peer): announces support at node level and for this topic. + test.mockRouters[2].sendToSingle(controlExtensionsWithPartial()) + test.mockRouters[2].sendToSingle(subscribeRpc(requestsPartial = false, supportsSendingPartial = true)) + + // Ensure flags are applied before the message arrives. + test.gossipRouter.submitOnEventThread {}.join() + + // Publisher sends a message, triggering IDONTWANT emission to mesh peers. + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.mockRouters[0].sendToSingle( + Rpc.RPC.newBuilder().addPublish(msg.protobufMessage).build() + ) + test.fuzz.timeController.addTime(100.millis) + + // mockRouters[2] (partial-peer) MUST NOT receive IDONTWANT. + val iDontWantsToPartialPeer = test.mockRouters[2].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.idontwantList } + assertThat(iDontWantsToPartialPeer).isEmpty() + + // mockRouters[1] (non-partial peer) MUST receive IDONTWANT. + val iDontWantsToNonPartialPeer = test.mockRouters[1].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.idontwantList } + assertThat(iDontWantsToNonPartialPeer).isNotEmpty() + } + + @Test + fun `IDONTWANT still sent when partial extension is disabled`() { + val test = ManyRoutersTest( + mockRouterCount = 2, + protocol = PubsubProtocol.Gossip_V_1_3, + params = GossipParams(iDontWantMinMessageSizeThreshold = 5), + ) + test.connectAll() + test.mockRouters.forEach { it.subscribe(topicId) } + test.gossipRouter.subscribe(topicId) + test.fuzz.timeController.addTime(2.seconds) + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.mockRouters[0].sendToSingle( + Rpc.RPC.newBuilder().addPublish(msg.protobufMessage).build() + ) + test.fuzz.timeController.addTime(100.millis) + + val iDontWants = test.mockRouters[1].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.idontwantList } + assertThat(iDontWants).isNotEmpty() + } + + @Test + fun `IDONTWANT still sent when we do not request partial for the topic`() { + val test = startNetwork() + + // We do NOT set requestsPartial=true for the topic. + // mockRouters[2] supports sending partial at both node and topic level. + test.mockRouters[2].sendToSingle(controlExtensionsWithPartial()) + test.mockRouters[2].sendToSingle(subscribeRpc(requestsPartial = false, supportsSendingPartial = true)) + test.gossipRouter.submitOnEventThread {}.join() + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.mockRouters[0].sendToSingle( + Rpc.RPC.newBuilder().addPublish(msg.protobufMessage).build() + ) + test.fuzz.timeController.addTime(100.millis) + + // IDONTWANT IS sent because we never set requestsPartial=true locally. + val iDontWantsToPartialPeer = test.mockRouters[2].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.idontwantList } + assertThat(iDontWantsToPartialPeer).isNotEmpty() + } + + @Test + fun `IDONTWANT still sent when peer did not announce supportsSendingPartial for the topic`() { + val test = startNetwork() + + // We request partial, but mockRouters[2] only announces node-level support + // without a SubOpts supportsSendingPartial flag for this topic. + test.gossipRouter.setTopicPartialFlags(topicId, requestsPartial = true, supportsSendingPartial = false) + test.mockRouters[2].sendToSingle(controlExtensionsWithPartial()) + // No SubOpts with supportsSendingPartial=true — partialSubscriptionState has nothing. + test.gossipRouter.submitOnEventThread {}.join() + + val msg = newMessage(topicId, 0L, "Hello".toByteArray()) + test.mockRouters[0].sendToSingle( + Rpc.RPC.newBuilder().addPublish(msg.protobufMessage).build() + ) + test.fuzz.timeController.addTime(100.millis) + + // IDONTWANT IS sent because the peer has no topic-level supportsSendingPartial. + val iDontWantsToPartialPeer = test.mockRouters[2].inboundMessages + .filter { it.hasControl() } + .flatMap { it.control.idontwantList } + assertThat(iDontWantsToPartialPeer).isNotEmpty() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesInboundRpcTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesInboundRpcTest.kt new file mode 100644 index 000000000..682f7282b --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesInboundRpcTest.kt @@ -0,0 +1,177 @@ +package io.libp2p.pubsub.gossip.extensions + +import com.google.protobuf.ByteString +import io.libp2p.core.PeerId +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.Topic +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipTestsBase +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc +import java.util.concurrent.CopyOnWriteArrayList + +private const val TIMEOUT_MS = 500L + +class PartialMessagesInboundRpcTest : GossipTestsBase() { + + private val topicId = "test-topic" + private val groupIdBytes = "group-1".toByteArray() + + /** Records each [onIncomingRpc] call for assertion in tests. */ + data class IncomingCall(val from: PeerId, val rpc: Rpc.PartialMessagesExtension) + + private val incomingCalls = CopyOnWriteArrayList() + + private val capturingHandler: PartialMessagesHandler = + object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback + ) { + incomingCalls += IncomingCall(from, rpc) + } + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback + ) {} + } + + private fun newTest() = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = capturingHandler, + ) + + private fun partialRpcWith( + topicId: String? = this.topicId, + groupId: ByteArray? = groupIdBytes + ): Rpc.RPC { + val ext = Rpc.PartialMessagesExtension.newBuilder().apply { + if (topicId != null) setTopicID(topicId) + if (groupId != null) setGroupID(ByteString.copyFrom(groupId)) + }.build() + return Rpc.RPC.newBuilder().setPartial(ext).build() + } + + private fun controlExtensionsWithPartial(): Rpc.RPC = + Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions( + Rpc.ControlExtensions.newBuilder().setPartialMessages(true) + ) + ).build() + + // Drains any currently queued messages from the mock router's outbox + // so later assertions start from a clean slate. + private fun TwoRoutersTest.flushRouter() = + gossipRouter.submitOnEventThread {}.join() + + @Test + fun `valid partial RPC after ControlExtensions dispatches to handler`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith()) + test.flushRouter() + + assertThat(incomingCalls).hasSize(1) + assertThat(incomingCalls[0].rpc.topicID).isEqualTo(topicId) + assertThat(incomingCalls[0].rpc.groupID.toByteArray()).isEqualTo(groupIdBytes) + } + + @Test + fun `partial RPC without prior ControlExtensions is ignored`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(partialRpcWith()) + test.flushRouter() + + assertThat(incomingCalls).isEmpty() + } + + @Test + fun `partial RPC with missing topicID is dropped`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith(topicId = null)) + test.flushRouter() + + assertThat(incomingCalls).isEmpty() + } + + @Test + fun `partial RPC with empty topicID is dropped`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith(topicId = "")) + test.flushRouter() + + assertThat(incomingCalls).isEmpty() + } + + @Test + fun `partial RPC with missing groupID is dropped`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith(groupId = null)) + test.flushRouter() + + assertThat(incomingCalls).isEmpty() + } + + @Test + fun `partial RPC with empty groupID is dropped`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith(groupId = ByteArray(0))) + test.flushRouter() + + assertThat(incomingCalls).isEmpty() + } + + @Test + fun `partial RPC when extension is disabled is ignored`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(), + ) + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith()) + test.flushRouter() + + assertThat(incomingCalls).isEmpty() + } + + @Test + fun `multiple valid partial RPCs for different groups all dispatched`() { + val test = newTest() + test.flushRouter() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpcWith(groupId = "g1".toByteArray())) + test.mockRouter.sendToSingle(partialRpcWith(groupId = "g2".toByteArray())) + test.flushRouter() + + assertThat(incomingCalls).hasSize(2) + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesLifecycleTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesLifecycleTest.kt new file mode 100644 index 000000000..e184f7617 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesLifecycleTest.kt @@ -0,0 +1,203 @@ +package io.libp2p.pubsub.gossip.extensions + +import com.google.protobuf.ByteString +import io.libp2p.core.PeerId +import io.libp2p.etc.types.seconds +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipTestsBase +import io.libp2p.pubsub.gossip.partialmessages.PartialGroupStateStore +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesAdapterImpl +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn +import io.libp2p.pubsub.gossip.partialmessages.toGroupId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +/** + * Tests for Step 8 — heartbeat tick + TTL GC + cleanup hooks (§6.4). + * + * Verifies that the three wiring points added in GossipRouter actually invoke + * the partial-messages adapter at the right times: + * - heartbeat → TTL decrement and GC of expired groups + * - onPeerDisconnected → peer state removed from all groups + * - unsubscribe → all group state for the topic dropped + */ +class PartialMessagesLifecycleTest : GossipTestsBase() { + + private val topicId = "test-topic" + private val groupId = "group-1".toByteArray() + + private fun newTest() = + TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + + @Suppress("UNCHECKED_CAST") + private fun TwoRoutersTest.store(): PartialGroupStateStore = + (gossipRouter.partialMessages as PartialMessagesAdapterImpl).stateStore + + /** + * Seeds a group with one peer-state entry so peerStates is non-empty and + * the group survives heartbeats until its TTL expires (not GC'd immediately + * by the peerStates-empty condition). + */ + private fun TwoRoutersTest.seedGroup(peer: PeerId) { + gossipRouter.submitOnEventThread { + val group = store().getOrCreateLocalGroup(topicId, groupId.toGroupId()) + group.peerStates[peer] = "sentinel" + }.join() + } + + private fun controlExtensionsWithPartial(): Rpc.RPC = + Rpc.RPC.newBuilder() + .setControl( + Rpc.ControlMessage.newBuilder() + .setExtensions(Rpc.ControlExtensions.newBuilder().setPartialMessages(true)) + ) + .build() + + private fun partialRpc(): Rpc.RPC = + Rpc.RPC.newBuilder() + .setPartial( + Rpc.PartialMessagesExtension.newBuilder() + .setTopicID(topicId) + .setGroupID(ByteString.copyFrom(groupId)) + ) + .build() + + // ── Heartbeat GC ────────────────────────────────────────────────────────── + + @Test + fun `heartbeat GCs peer-initiated group whose peerStates is empty`() { + val test = newTest() + + // Peer-initiated group via inbound RPC; nopHandler sets no peerStates → empty + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(partialRpc()) + test.gossipRouter.submitOnEventThread {}.join() + + val store = test.store() + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNotNull() + + // One heartbeat fires; peerStates.isEmpty() triggers immediate GC + test.fuzz.timeController.addTime(2.seconds) + + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNull() + } + + @Test + fun `heartbeat decrements TTL and GCs group after TTL expires`() { + val test = newTest() + val mockPeerId = test.router2.peerId + test.seedGroup(mockPeerId) + + val store = test.store() + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNotNull() + + // DEFAULT_GROUP_TTL_HEARTBEATS = 5; advance 6 s to fire 6 heartbeats + test.fuzz.timeController.addTime(6.seconds) + + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNull() + } + + @Test + fun `publishPartial resets TTL so group survives past initial TTL`() { + val test = newTest() + val mockPeerId = test.router2.peerId + test.seedGroup(mockPeerId) + + val store = test.store() + + // Advance 4 heartbeats: TTL goes 5→4→3→2→1; group still alive + test.fuzz.timeController.addTime(4.seconds) + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNotNull() + + // publishPartial with empty actions still calls getOrCreateLocalGroup → resets TTL + test.gossipRouter.publishPartial(topicId, groupId, PublishActionsFn { _, _ -> emptySequence() }) + + // Advance 4 more heartbeats: TTL goes 5→4→3→2→1; group still alive + test.fuzz.timeController.addTime(4.seconds) + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNotNull() + } + + // ── Peer disconnect ─────────────────────────────────────────────────────── + + @Test + fun `peer disconnect removes peer from group state and GCs now-empty group`() { + val test = newTest() + val mockPeerId = test.router2.peerId + test.seedGroup(mockPeerId) + + val store = test.store() + assertThat(store.getGroup(topicId, groupId.toGroupId())?.peerStates).containsKey(mockPeerId) + + test.connection.disconnect() + test.gossipRouter.submitOnEventThread {}.join() + + // peerStates is now empty → group GC'd immediately in onPeerDisconnected + assertThat(store.getGroup(topicId, groupId.toGroupId())).isNull() + } + + @Test + fun `peer disconnect leaves group alive when other peers still have state`() { + val test = newTest() + val mockPeerId = test.router2.peerId + val otherPeerId = PeerId.random() + + test.gossipRouter.submitOnEventThread { + val group = test.store().getOrCreateLocalGroup(topicId, groupId.toGroupId()) + group.peerStates[mockPeerId] = "mock-state" + group.peerStates[otherPeerId] = "other-state" + }.join() + + test.connection.disconnect() + test.gossipRouter.submitOnEventThread {}.join() + + // Group survives because otherPeerId still has state + val group = test.store().getGroup(topicId, groupId.toGroupId()) + assertThat(group).isNotNull() + assertThat(group?.peerStates).doesNotContainKey(mockPeerId) + assertThat(group?.peerStates).containsKey(otherPeerId) + } + + // ── Unsubscribe ─────────────────────────────────────────────────────────── + + @Test + fun `unsubscribing from topic drops all group state for that topic`() { + val test = newTest() + test.gossipRouter.subscribe(topicId) + val mockPeerId = test.router2.peerId + test.seedGroup(mockPeerId) + + val store = test.store() + assertThat(store.groupsForTopic(topicId)).isNotEmpty() + + test.gossipRouter.unsubscribe(topicId) + test.gossipRouter.submitOnEventThread {}.join() + + assertThat(store.groupsForTopic(topicId)).isEmpty() + } + + @Test + fun `unsubscribing from one topic does not affect groups on other topics`() { + val otherTopic = "other-topic" + val test = newTest() + test.gossipRouter.subscribe(topicId) + val mockPeerId = test.router2.peerId + + test.gossipRouter.submitOnEventThread { + val store = test.store() + store.getOrCreateLocalGroup(topicId, groupId.toGroupId()).peerStates[mockPeerId] = "s1" + store.getOrCreateLocalGroup(otherTopic, groupId.toGroupId()).peerStates[mockPeerId] = "s2" + }.join() + + test.gossipRouter.unsubscribe(topicId) + test.gossipRouter.submitOnEventThread {}.join() + + assertThat(test.store().groupsForTopic(topicId)).isEmpty() + assertThat(test.store().groupsForTopic(otherTopic)).isNotEmpty() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesMixedPeerTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesMixedPeerTest.kt new file mode 100644 index 000000000..bcc9e0690 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesMixedPeerTest.kt @@ -0,0 +1,274 @@ +package io.libp2p.pubsub.gossip.extensions + +import com.google.protobuf.ByteString +import io.libp2p.core.PeerId +import io.libp2p.core.dsl.host +import io.libp2p.core.mux.StreamMuxerProtocol +import io.libp2p.core.pubsub.ValidationResult +import io.libp2p.core.pubsub.Validator +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.Topic +import io.libp2p.pubsub.gossip.Gossip +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipRouter +import io.libp2p.pubsub.gossip.builders.GossipRouterBuilder +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback +import io.libp2p.pubsub.gossip.partialmessages.PublishAction +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn +import io.libp2p.security.noise.NoiseXXSecureChannel +import io.libp2p.transport.tcp.TcpTransport +import io.netty.buffer.Unpooled +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import io.libp2p.core.pubsub.Topic as ApiTopic + +/** + * Mixed-peer interop test (Step 10). + * + * Three real libp2p hosts on the same topic: + * - nodeA: partial-capable (requests + supports partial) + * - nodeB: partial-capable (requests + supports partial) + * - nodeC: non-partial (Gossip v1.3 without PARTIAL_MESSAGES extension) + * + * Topology: A—B and A—C (star, A is the hub). + * + * Key assertions: + * 1. Full message from A is suppressed for B (partial), delivered to C (non-partial). + * 2. Partial RPC from A reaches B but not C. + * 3. Full message from C (non-partial sender) is received by A even though A is partial-capable; + * non-partial senders cannot honour the partial-request and send full messages unconditionally. + */ +class PartialMessagesMixedPeerTest { + + private val topic = "mixed-peer-topic" + private val groupId = "group-mixed".toByteArray() + + private val nodeBPartialRpcs = CopyOnWriteArrayList() + private val nodeBFullMessages = CopyOnWriteArrayList() + private val nodeCFullMessages = CopyOnWriteArrayList() + private val nodeAFullMessages = CopyOnWriteArrayList() + + private fun bHandler(): PartialMessagesHandler = + object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) { + nodeBPartialRpcs += rpc + } + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) {} + } + + private fun buildPartialRouter(handler: PartialMessagesHandler) = + GossipRouterBuilder( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = handler, + ).build() + + private fun buildNonPartialRouter() = + GossipRouterBuilder( + protocol = PubsubProtocol.Gossip_V_1_3, + ).build() + + private val routerA by lazy { buildPartialRouter(nopPartialMessagesHandler) } + private val routerB by lazy { buildPartialRouter(bHandler()) } + private val routerC by lazy { buildNonPartialRouter() } + + private val gossipA by lazy { Gossip(routerA) } + private val gossipB by lazy { Gossip(routerB) } + private val gossipC by lazy { Gossip(routerC) } + + private fun buildHost(gossip: Gossip) = host { + identity { random() } + transports { add(::TcpTransport) } + network { listen("/ip4/127.0.0.1/tcp/0") } + secureChannels { add(::NoiseXXSecureChannel) } + muxers { +StreamMuxerProtocol.Mplex } + protocols { +gossip } + } + + private val hostA by lazy { buildHost(gossipA) } + private val hostB by lazy { buildHost(gossipB) } + private val hostC by lazy { buildHost(gossipC) } + + @BeforeEach + fun setUp() { + hostA.start().get(5, TimeUnit.SECONDS) + hostB.start().get(5, TimeUnit.SECONDS) + hostC.start().get(5, TimeUnit.SECONDS) + } + + @AfterEach + fun tearDown() { + hostA.stop().get(5, TimeUnit.SECONDS) + hostB.stop().get(5, TimeUnit.SECONDS) + hostC.stop().get(5, TimeUnit.SECONDS) + } + + /** + * Connects the three hosts and waits for all handshakes to settle: + * - ControlExtensions exchanged between A↔B and A↔C + * - SubOpts with partial flags from B→A and A→B + */ + private fun connectMixedNetwork(): Triple { + // Partial flags must be set before subscribing so they are included in the + // SubOpts sent on peer activation (onPeerActive → enqueueSubscribe reads them). + routerA.setTopicPartialFlags(topic, requestsPartial = true, supportsSendingPartial = true) + routerB.setTopicPartialFlags(topic, requestsPartial = true, supportsSendingPartial = true) + + gossipA.subscribe( + Validator { msg -> + nodeAFullMessages += msg.data.array().copyOf() + CompletableFuture.completedFuture(ValidationResult.Valid) + }, + ApiTopic(topic), + ) + gossipB.subscribe( + Validator { msg -> + nodeBFullMessages += msg.data.array().copyOf() + CompletableFuture.completedFuture(ValidationResult.Valid) + }, + ApiTopic(topic), + ) + gossipC.subscribe( + Validator { msg -> + nodeCFullMessages += msg.data.array().copyOf() + CompletableFuture.completedFuture(ValidationResult.Valid) + }, + ApiTopic(topic), + ) + + hostA.network.connect(hostB.peerId, hostB.listenAddresses().first()).get(10, TimeUnit.SECONDS) + hostA.network.connect(hostC.peerId, hostC.listenAddresses().first()).get(10, TimeUnit.SECONDS) + + val peerAId = hostA.peerId + val peerBId = hostB.peerId + val peerCId = hostC.peerId + + // Wait for ControlExtensions handshake between A and B (both partial-capable). + waitForOnEventThread(routerA) { routerA.gossipExtensionsState.peerSupportsPartialMessages(peerBId) } + waitForOnEventThread(routerB) { routerB.gossipExtensionsState.peerSupportsPartialMessages(peerAId) } + // Wait for A to have received ControlExtensions from C (non-partial — partialMessages=false). + waitForOnEventThread(routerA) { routerA.gossipExtensionsState.hasReceivedControlExtensionsFrom(peerCId) } + + // Wait for partial SubOpts: B→A and A→B. + waitForOnEventThread(routerA) { routerA.partialSubscriptionState.peerRequestsPartial(topic, peerBId) } + waitForOnEventThread(routerB) { routerB.partialSubscriptionState.peerRequestsPartial(topic, peerAId) } + + return Triple(peerAId, peerBId, peerCId) + } + + private fun waitFor(predicate: () -> Boolean) { + repeat(100) { + if (predicate()) return + Thread.sleep(100) + } + throw TimeoutException("Timed out waiting for condition") + } + + private fun waitForOnEventThread(router: GossipRouter, predicate: () -> Boolean) { + waitFor { router.submitOnEventThread { predicate() }.get(1, TimeUnit.SECONDS) } + } + + // ── Test 1: full-message suppression ──────────────────────────────────── + + @Test + fun `full message from partial node reaches non-partial peer but not partial peer`() { + connectMixedNetwork() + + val payload = "hello mixed network".toByteArray() + gossipA.createPublisher(hostA.privKey, 0L) + .publish(Unpooled.wrappedBuffer(payload), ApiTopic(topic)) + .get(5, TimeUnit.SECONDS) + + // C (non-partial) MUST receive the full message. + waitFor { nodeCFullMessages.isNotEmpty() } + assertThat(nodeCFullMessages.first()).isEqualTo(payload) + + // B (partial, requested suppression) MUST NOT receive the full message. + Thread.sleep(500) + assertThat(nodeBFullMessages).isEmpty() + } + + // ── Test 2: partial RPC delivery ──────────────────────────────────────── + + @Test + fun `partial RPC from partial node reaches partial peer`() { + val (_, peerBId, _) = connectMixedNetwork() + + val partPayload = byteArrayOf(0xCA.toByte(), 0xFE.toByte()) + val partMeta = byteArrayOf(0x01) + + gossipA.publishPartial( + topic, + groupId, + PublishActionsFn { _, _ -> + sequenceOf(peerBId to PublishAction(partialMessage = partPayload, partsMetadata = partMeta)) + }, + ).get(5, TimeUnit.SECONDS) + + waitFor { nodeBPartialRpcs.isNotEmpty() } + val rpc = nodeBPartialRpcs.single() + assertThat(rpc.topicID).isEqualTo(topic) + assertThat(rpc.groupID).isEqualTo(ByteString.copyFrom(groupId)) + assertThat(rpc.partialMessage.toByteArray()).isEqualTo(partPayload) + assertThat(rpc.partsMetadata.toByteArray()).isEqualTo(partMeta) + } + + // ── Test 3: non-partial sender can still deliver to partial-capable nodes ─ + + @Test + fun `non-partial node sends full message received by partial-capable direct peer`() { + // C (non-partial) publishes. A (partial-capable, directly connected to C) receives + // the full message because: + // - Suppression is OUTBOUND only (A suppresses when it would send TO B). + // - A still RECEIVES full messages from peers that don't support partial. + connectMixedNetwork() + + val payload = "from non-partial node C".toByteArray() + gossipC.createPublisher(hostC.privKey, 0L) + .publish(Unpooled.wrappedBuffer(payload), ApiTopic(topic)) + .get(5, TimeUnit.SECONDS) + + waitFor { nodeAFullMessages.isNotEmpty() } + assertThat(nodeAFullMessages.first()).isEqualTo(payload) + } + + // ── Helper: no-op handler for nodeA ───────────────────────────────────── + + private val nopPartialMessagesHandler: PartialMessagesHandler = + object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) {} + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) {} + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesOutboundRpcTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesOutboundRpcTest.kt new file mode 100644 index 000000000..9fd02ace0 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialMessagesOutboundRpcTest.kt @@ -0,0 +1,172 @@ +package io.libp2p.pubsub.gossip.extensions + +import com.google.protobuf.ByteString +import io.libp2p.core.PeerId +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipTestsBase +import io.libp2p.pubsub.gossip.partialmessages.PublishAction +import io.libp2p.pubsub.gossip.partialmessages.PublishActionsFn +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +private const val TIMEOUT_MS = 500L + +class PartialMessagesOutboundRpcTest : GossipTestsBase() { + + private val topicId = "test-topic" + private val groupIdBytes = "group-1".toByteArray() + + private fun newTest() = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + + private fun controlExtensionsWithPartial(): Rpc.RPC = + Rpc.RPC.newBuilder().setControl( + Rpc.ControlMessage.newBuilder().setExtensions( + Rpc.ControlExtensions.newBuilder().setPartialMessages(true) + ) + ).build() + + private fun subscribeRpc( + topic: String, + requestsPartial: Boolean, + supportsSendingPartial: Boolean + ): Rpc.RPC = + Rpc.RPC.newBuilder().addSubscriptions( + Rpc.RPC.SubOpts.newBuilder() + .setTopicid(topic) + .setSubscribe(true) + .setRequestsPartial(requestsPartial) + .setSupportsSendingPartial(supportsSendingPartial) + ).build() + + private fun TwoRoutersTest.flushRouter() = + gossipRouter.submitOnEventThread {}.join() + + private fun TwoRoutersTest.peerIdOfMockRouter(): PeerId = router2.peerId + + @Test + fun `publishPartial delivers partial RPC to peer that requested partial`() { + val test = newTest() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(subscribeRpc(topicId, requestsPartial = true, supportsSendingPartial = true)) + test.flushRouter() + + val payload = byteArrayOf(1, 2, 3) + val meta = byteArrayOf(0xAA.toByte()) + val peerId = test.peerIdOfMockRouter() + + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peerId to PublishAction(partialMessage = payload, partsMetadata = meta)) + } + + test.gossipRouter.publishPartial(topicId, groupIdBytes, actionsFn) + + val received = test.mockRouter.waitForMessage({ it.hasPartial() }, TIMEOUT_MS) + assertThat(received.partial.topicID).isEqualTo(topicId) + assertThat(received.partial.groupID).isEqualTo(ByteString.copyFrom(groupIdBytes)) + assertThat(received.partial.partialMessage.toByteArray()).isEqualTo(payload) + assertThat(received.partial.partsMetadata.toByteArray()).isEqualTo(meta) + } + + @Test + fun `publishPartial omits partialMessage when peer supports but did not request`() { + val test = newTest() + + // Peer supports sending partial but did NOT request partial messages + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(subscribeRpc(topicId, requestsPartial = false, supportsSendingPartial = true)) + test.flushRouter() + + val payload = byteArrayOf(1, 2, 3) + val meta = byteArrayOf(0xAA.toByte()) + val peerId = test.peerIdOfMockRouter() + + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peerId to PublishAction(partialMessage = payload, partsMetadata = meta)) + } + + test.gossipRouter.publishPartial(topicId, groupIdBytes, actionsFn) + + val received = test.mockRouter.waitForMessage({ it.hasPartial() }, TIMEOUT_MS) + // partsMetadata is present; partialMessage MUST be absent (spec MUST) + assertThat(received.partial.hasPartialMessage()).isFalse() + assertThat(received.partial.partsMetadata.toByteArray()).isEqualTo(meta) + } + + @Test + fun `publishPartial sends nothing when actionsFn returns empty sequence`() { + val test = newTest() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(subscribeRpc(topicId, requestsPartial = true, supportsSendingPartial = true)) + test.flushRouter() + + val actionsFn = PublishActionsFn { _, _ -> emptySequence() } + + test.gossipRouter.publishPartial(topicId, groupIdBytes, actionsFn) + test.flushRouter() + + assertThat(test.mockRouter.inboundMessages.none { it.hasPartial() }).isTrue() + } + + @Test + fun `publishPartial sends nothing when adapter is not configured`() { + val test = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(), + ) + test.flushRouter() + + val peerId = test.peerIdOfMockRouter() + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peerId to PublishAction(partsMetadata = byteArrayOf(1))) + } + + test.gossipRouter.publishPartial(topicId, groupIdBytes, actionsFn) + test.flushRouter() + + assertThat(test.mockRouter.inboundMessages.none { it.hasPartial() }).isTrue() + } + + @Test + fun `publishPartial two groups produce two separate RPCs`() { + val test = newTest() + + test.mockRouter.sendToSingle(controlExtensionsWithPartial()) + test.mockRouter.sendToSingle(subscribeRpc(topicId, requestsPartial = true, supportsSendingPartial = true)) + test.flushRouter() + + val peerId = test.peerIdOfMockRouter() + val groupA = "group-a".toByteArray() + val groupB = "group-b".toByteArray() + + test.gossipRouter.publishPartial( + topicId, + groupA, + PublishActionsFn { _, _ -> sequenceOf(peerId to PublishAction(partsMetadata = byteArrayOf(1))) } + ) + test.gossipRouter.publishPartial( + topicId, + groupB, + PublishActionsFn { _, _ -> sequenceOf(peerId to PublishAction(partsMetadata = byteArrayOf(2))) } + ) + + val rpc1 = test.mockRouter.waitForMessage({ it.hasPartial() }, TIMEOUT_MS) + val rpc2 = test.mockRouter.waitForMessage({ it.hasPartial() }, TIMEOUT_MS) + + val groupIds = setOf( + rpc1.partial.groupID.toByteArray().toList(), + rpc2.partial.groupID.toByteArray().toList() + ) + assertThat(groupIds).containsExactlyInAnyOrder( + groupA.toList(), + groupB.toList() + ) + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialSubscriptionWireTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialSubscriptionWireTest.kt new file mode 100644 index 000000000..9c3c9ac55 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/extensions/PartialSubscriptionWireTest.kt @@ -0,0 +1,205 @@ +package io.libp2p.pubsub.gossip.extensions + +import io.libp2p.core.PeerId +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.Topic +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.GossipTestsBase +import io.libp2p.pubsub.gossip.PartialSubFlags +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +class PartialSubscriptionWireTest : GossipTestsBase() { + + private val topicA = "topic-a" + private val topicB = "topic-b" + + private fun newTest() = TwoRoutersTest( + protocol = PubsubProtocol.Gossip_V_1_3, + enabledGossipExtensions = listOf(GossipExtension.PARTIAL_MESSAGES), + partialMessagesHandler = nopPartialMessagesHandler, + ) + + private fun Rpc.RPC.firstSubscribeFor(topic: String): Rpc.RPC.SubOpts? = + subscriptionsList.firstOrNull { it.topicid == topic && it.subscribe } + + private fun Rpc.RPC.firstUnsubscribeFor(topic: String): Rpc.RPC.SubOpts? = + subscriptionsList.firstOrNull { it.topicid == topic && !it.subscribe } + + /** + * Reads `partialSubscriptionState.peerFlags` on the pubsub event loop so the + * test thread establishes a happens-before with any pending event-loop + * mutations. The state container is documented as not thread-safe; direct + * access from the test thread risks `ConcurrentModificationException` and + * stale reads. + */ + private fun TwoRoutersTest.peerFlagsOnEventLoop(topic: Topic, peer: PeerId): PartialSubFlags = + gossipRouter.submitOnEventThread { + gossipRouter.partialSubscriptionState.peerFlags(topic, peer) + }.join() + + private fun TwoRoutersTest.snapshotPartialStateOnEventLoop(): Map> = + gossipRouter.submitOnEventThread { + gossipRouter.partialSubscriptionState.snapshot() + }.join() + + @Test + fun `outbound subscribe carries configured partial flags with send-side coercion`() { + val test = newTest() + + test.gossipRouter.setTopicPartialFlags(topicA, requestsPartial = true, supportsSendingPartial = false) + test.gossipRouter.subscribe(topicA) + + val received = test.mockRouter.waitForMessage({ it.firstSubscribeFor(topicA) != null }) + val sub = received.firstSubscribeFor(topicA)!! + assertThat(sub.requestsPartial).isTrue() + // spec coercion: supportsSendingPartial := requestsPartial || supportsSendingPartial + assertThat(sub.supportsSendingPartial).isTrue() + } + + @Test + fun `outbound subscribe with only supportsSendingPartial carries only that flag`() { + val test = newTest() + + test.gossipRouter.setTopicPartialFlags(topicA, requestsPartial = false, supportsSendingPartial = true) + test.gossipRouter.subscribe(topicA) + + val received = test.mockRouter.waitForMessage({ it.firstSubscribeFor(topicA) != null }) + val sub = received.firstSubscribeFor(topicA)!! + assertThat(sub.requestsPartial).isFalse() + assertThat(sub.supportsSendingPartial).isTrue() + } + + @Test + fun `outbound subscribe without configured flags has both flags absent`() { + val test = newTest() + + test.gossipRouter.subscribe(topicA) + + val received = test.mockRouter.waitForMessage({ it.firstSubscribeFor(topicA) != null }) + val sub = received.firstSubscribeFor(topicA)!! + assertThat(sub.hasRequestsPartial()).isFalse() + assertThat(sub.hasSupportsSendingPartial()).isFalse() + } + + @Test + fun `outbound unsubscribe never carries partial flags`() { + val test = newTest() + + test.gossipRouter.setTopicPartialFlags(topicA, requestsPartial = true, supportsSendingPartial = true) + test.gossipRouter.subscribe(topicA) + test.mockRouter.waitForMessage({ it.firstSubscribeFor(topicA) != null }) + + test.gossipRouter.unsubscribe(topicA) + + val received = test.mockRouter.waitForMessage({ it.firstUnsubscribeFor(topicA) != null }) + val unsub = received.firstUnsubscribeFor(topicA)!! + assertThat(unsub.hasRequestsPartial()).isFalse() + assertThat(unsub.hasSupportsSendingPartial()).isFalse() + } + + @Test + fun `inbound subscribe with requestsPartial only stores coerced flags`() { + val test = newTest() + + val rpc = subscribeRpc(topicA, requestsPartial = true, supportsSendingPartial = false) + test.mockRouter.sendToSingle(rpc) + + val peerId = test.router2.peerId + // Receive-side coercion: supportsSendingPartial := requestsPartial || supportsSendingPartial + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + } + + @Test + fun `inbound subscribe with supportsSendingPartial only stores that flag verbatim`() { + val test = newTest() + + val rpc = subscribeRpc(topicA, requestsPartial = false, supportsSendingPartial = true) + test.mockRouter.sendToSingle(rpc) + + assertThat(test.peerFlagsOnEventLoop(topicA, test.router2.peerId)) + .isEqualTo(PartialSubFlags(requestsPartial = false, supportsSendingPartial = true)) + } + + @Test + fun `inbound subscribe with both flags false leaves state empty`() { + val test = newTest() + + val rpc = subscribeRpc(topicA, requestsPartial = false, supportsSendingPartial = false) + test.mockRouter.sendToSingle(rpc) + + assertThat(test.peerFlagsOnEventLoop(topicA, test.router2.peerId)) + .isEqualTo(PartialSubFlags.NONE) + assertThat(test.snapshotPartialStateOnEventLoop()).doesNotContainKey(topicA) + } + + @Test + fun `inbound unsubscribe ignores flags and clears any prior peer state`() { + val test = newTest() + val peerId = test.router2.peerId + + test.mockRouter.sendToSingle(subscribeRpc(topicA, requestsPartial = true, supportsSendingPartial = true)) + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + // Unsubscribe with malicious flags set: flags MUST be ignored, state MUST be cleared. + val unsub = Rpc.RPC.newBuilder().addSubscriptions( + Rpc.RPC.SubOpts.newBuilder() + .setTopicid(topicA) + .setSubscribe(false) + .setRequestsPartial(true) + .setSupportsSendingPartial(true) + ).build() + test.mockRouter.sendToSingle(unsub) + + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags.NONE) + } + + @Test + fun `peer disconnect clears stored partial subscription state`() { + val test = newTest() + val peerId = test.router2.peerId + + test.mockRouter.sendToSingle(subscribeRpc(topicA, requestsPartial = true, supportsSendingPartial = true)) + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + test.connection.disconnect() + + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags.NONE) + } + + @Test + fun `local unsubscribe clears stored partial subscription state for that topic`() { + val test = newTest() + val peerId = test.router2.peerId + + test.gossipRouter.subscribe(topicA) + test.mockRouter.sendToSingle(subscribeRpc(topicA, requestsPartial = true, supportsSendingPartial = true)) + test.mockRouter.sendToSingle(subscribeRpc(topicB, requestsPartial = true, supportsSendingPartial = true)) + + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + + test.gossipRouter.unsubscribe(topicA) + + assertThat(test.peerFlagsOnEventLoop(topicA, peerId)) + .isEqualTo(PartialSubFlags.NONE) + // Other topic state preserved + assertThat(test.peerFlagsOnEventLoop(topicB, peerId)) + .isEqualTo(PartialSubFlags(requestsPartial = true, supportsSendingPartial = true)) + } + + private fun subscribeRpc(topic: String, requestsPartial: Boolean, supportsSendingPartial: Boolean): Rpc.RPC = + Rpc.RPC.newBuilder().addSubscriptions( + Rpc.RPC.SubOpts.newBuilder() + .setTopicid(topic) + .setSubscribe(true) + .setRequestsPartial(requestsPartial) + .setSupportsSendingPartial(supportsSendingPartial) + ).build() +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialGroupStateStoreTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialGroupStateStoreTest.kt new file mode 100644 index 000000000..7d1c0cdb8 --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialGroupStateStoreTest.kt @@ -0,0 +1,245 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import io.libp2p.core.PeerId +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +class PartialGroupStateStoreTest { + + private lateinit var store: PartialGroupStateStore + private lateinit var peer1: PeerId + private lateinit var peer2: PeerId + + private val topicA = "topic-a" + private val topicB = "topic-b" + private val groupId1 = "group-1".toByteArray().toGroupId() + private val groupId2 = "group-2".toByteArray().toGroupId() + + @BeforeEach + fun setup() { + store = PartialGroupStateStore(groupTtlHeartbeats = 3) + peer1 = PeerId.random() + peer2 = PeerId.random() + } + + // --- GroupId equality --- + + @Test + fun `GroupId equality is content-based`() { + val a = "abc".toByteArray().toGroupId() + val b = "abc".toByteArray().toGroupId() + val c = "xyz".toByteArray().toGroupId() + assertThat(a).isEqualTo(b) + assertThat(a).isNotEqualTo(c) + assertThat(a.hashCode()).isEqualTo(b.hashCode()) + } + + @Test + fun `GroupId works as HashMap key`() { + val map = HashMap() + map["abc".toByteArray().toGroupId()] = 42 + assertThat(map["abc".toByteArray().toGroupId()]).isEqualTo(42) + } + + // --- local groups --- + + @Test + fun `getOrCreateLocalGroup creates a new group`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + assertThat(group.peerInitiated).isFalse() + assertThat(group.initiatingPeer).isNull() + assertThat(group.ttlInHeartbeats).isEqualTo(3) + assertThat(store.getGroup(topicA, groupId1)).isSameAs(group) + } + + @Test + fun `getOrCreateLocalGroup resets TTL on existing group`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.ttlInHeartbeats = 1 + store.getOrCreateLocalGroup(topicA, groupId1) + assertThat(group.ttlInHeartbeats).isEqualTo(3) + } + + @Test + fun `getOrCreateLocalGroup returns same object on repeated calls`() { + val g1 = store.getOrCreateLocalGroup(topicA, groupId1) + val g2 = store.getOrCreateLocalGroup(topicA, groupId1) + assertThat(g1).isSameAs(g2) + } + + // --- peer groups --- + + @Test + fun `getOrCreatePeerGroup creates a peer-initiated group`() { + val group = store.getOrCreatePeerGroup(topicA, groupId1, peer1) + assertThat(group).isNotNull() + assertThat(group!!.peerInitiated).isTrue() + assertThat(group.initiatingPeer).isEqualTo(peer1) + assertThat(group.ttlInHeartbeats).isEqualTo(3) + } + + @Test + fun `getOrCreatePeerGroup returns existing group`() { + val g1 = store.getOrCreatePeerGroup(topicA, groupId1, peer1) + val g2 = store.getOrCreatePeerGroup(topicA, groupId1, peer1) + assertThat(g1).isSameAs(g2) + } + + @Test + fun `per-topic cap rejects new peer-initiated groups`() { + val smallCapStore = PartialGroupStateStore( + peerInitiatedGroupLimitPerTopic = 2 + ) + val g1id = "g1".toByteArray().toGroupId() + val g2id = "g2".toByteArray().toGroupId() + val g3id = "g3".toByteArray().toGroupId() + + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g1id, peer1)).isNotNull() + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g2id, peer1)).isNotNull() + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g3id, peer1)).isNull() + } + + @Test + fun `per-topic cap does not count local-initiated groups`() { + val smallCapStore = PartialGroupStateStore( + peerInitiatedGroupLimitPerTopic = 1 + ) + smallCapStore.getOrCreateLocalGroup(topicA, "local1".toByteArray().toGroupId()) + smallCapStore.getOrCreateLocalGroup(topicA, "local2".toByteArray().toGroupId()) + + // Only 0 peer-initiated groups, so cap not reached + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, groupId1, peer1)).isNotNull() + // Now cap reached (1 peer-initiated) + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, groupId2, peer2)).isNull() + } + + @Test + fun `per-peer cap rejects new peer-initiated groups for that peer`() { + val smallCapStore = PartialGroupStateStore( + peerInitiatedGroupLimitPerTopicPerPeer = 2 + ) + val g1id = "g1".toByteArray().toGroupId() + val g2id = "g2".toByteArray().toGroupId() + val g3id = "g3".toByteArray().toGroupId() + + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g1id, peer1)).isNotNull() + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g2id, peer1)).isNotNull() + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g3id, peer1)).isNull() + + // peer2 should still be allowed (different peer) + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, g3id, peer2)).isNotNull() + } + + @Test + fun `per-peer cap is per-topic — other topics are unaffected`() { + val smallCapStore = PartialGroupStateStore( + peerInitiatedGroupLimitPerTopicPerPeer = 1 + ) + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, groupId1, peer1)).isNotNull() + assertThat(smallCapStore.getOrCreatePeerGroup(topicA, groupId2, peer1)).isNull() + assertThat(smallCapStore.getOrCreatePeerGroup(topicB, groupId1, peer1)).isNotNull() + } + + // --- TTL and heartbeat GC --- + + @Test + fun `onHeartbeat decrements TTL`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.peerStates[peer1] = "state" // prevent GC by empty-peerStates rule + store.onHeartbeat() + assertThat(group.ttlInHeartbeats).isEqualTo(2) + } + + @Test + fun `onHeartbeat removes group when TTL reaches zero`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.peerStates[peer1] = "state" // prevent GC by empty-peerStates rule + repeat(3) { store.onHeartbeat() } + assertThat(store.getGroup(topicA, groupId1)).isNull() + } + + @Test + fun `onHeartbeat removes group when peerStates is empty`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.peerStates[peer1] = "state" + group.peerStates.remove(peer1) + store.onHeartbeat() + assertThat(store.getGroup(topicA, groupId1)).isNull() + } + + @Test + fun `onHeartbeat does not remove group with non-empty peerStates before TTL`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.peerStates[peer1] = "state" + store.onHeartbeat() + assertThat(store.getGroup(topicA, groupId1)).isSameAs(group) + } + + @Test + fun `resetTtl refreshes TTL for a group`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.peerStates[peer1] = "state" // prevent GC by empty-peerStates rule + repeat(2) { store.onHeartbeat() } + assertThat(group.ttlInHeartbeats).isEqualTo(1) + store.resetTtl(topicA, groupId1) + assertThat(group.ttlInHeartbeats).isEqualTo(3) + } + + // --- peer disconnect --- + + @Test + fun `onPeerDisconnected removes peer from all group peerStates`() { + val group1 = store.getOrCreateLocalGroup(topicA, groupId1) + val group2 = store.getOrCreateLocalGroup(topicB, groupId2) + group1.peerStates[peer1] = "state1" + group1.peerStates[peer2] = "state2" + group2.peerStates[peer1] = "state3" + + store.onPeerDisconnected(peer1) + + assertThat(group1.peerStates).containsOnlyKeys(peer2) + assertThat(group2.peerStates).isEmpty() + } + + @Test + fun `onPeerDisconnected GCs groups whose peerStates become empty`() { + val group = store.getOrCreateLocalGroup(topicA, groupId1) + group.peerStates[peer1] = "only-state" + + store.onPeerDisconnected(peer1) + + assertThat(store.getGroup(topicA, groupId1)).isNull() + assertThat(store.groupsForTopic(topicA)).isEmpty() + } + + // --- topic unsubscribe --- + + @Test + fun `onTopicUnsubscribed removes all groups for that topic`() { + store.getOrCreateLocalGroup(topicA, groupId1) + store.getOrCreateLocalGroup(topicA, groupId2) + store.getOrCreateLocalGroup(topicB, groupId1) + + store.onTopicUnsubscribed(topicA) + + assertThat(store.groupsForTopic(topicA)).isEmpty() + assertThat(store.groupsForTopic(topicB)).isNotEmpty() + } + + // --- groupsForTopic --- + + @Test + fun `groupsForTopic returns empty map for unknown topic`() { + assertThat(store.groupsForTopic("unknown-topic")).isEmpty() + } + + @Test + fun `groupsForTopic returns all groups for the topic`() { + store.getOrCreateLocalGroup(topicA, groupId1) + store.getOrCreatePeerGroup(topicA, groupId2, peer1) + + assertThat(store.groupsForTopic(topicA)).hasSize(2) + assertThat(store.groupsForTopic(topicB)).isEmpty() + } +} diff --git a/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesAdapterImplTest.kt b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesAdapterImplTest.kt new file mode 100644 index 000000000..61c3b95be --- /dev/null +++ b/libp2p/src/test/kotlin/io/libp2p/pubsub/gossip/partialmessages/PartialMessagesAdapterImplTest.kt @@ -0,0 +1,277 @@ +package io.libp2p.pubsub.gossip.partialmessages + +import com.google.protobuf.ByteString +import io.libp2p.core.PeerId +import io.libp2p.pubsub.Topic +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc + +class PartialMessagesAdapterImplTest { + + private val topic = "test-topic" + private val groupIdBytes = "group-1".toByteArray() + private lateinit var peer1: PeerId + private lateinit var peer2: PeerId + private lateinit var capturedCalls: MutableList> + private lateinit var adapter: PartialMessagesAdapterImpl + + data class IncomingRpcCall( + val from: PeerId, + val peerStates: Map, + val rpc: Rpc.PartialMessagesExtension + ) + + private fun makeHandler() = object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback + ) { + capturedCalls += IncomingRpcCall(from, peerStates, rpc) + } + + override fun onEmitGossip( + topic: Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback + ) {} + } + + @BeforeEach + fun setup() { + peer1 = PeerId.random() + peer2 = PeerId.random() + capturedCalls = mutableListOf() + adapter = PartialMessagesAdapterImpl( + handler = makeHandler(), + stateStore = PartialGroupStateStore(groupTtlHeartbeats = 3), + feedback = NopPartialMessagesFeedback + ) + } + + private fun buildRpc( + topicId: String = topic, + groupId: ByteArray = groupIdBytes, + partialMessage: ByteArray? = null, + partsMetadata: ByteArray? = null + ): Rpc.PartialMessagesExtension = + Rpc.PartialMessagesExtension.newBuilder() + .setTopicID(topicId) + .setGroupID(ByteString.copyFrom(groupId)) + .apply { + if (partialMessage != null) setPartialMessage(ByteString.copyFrom(partialMessage)) + if (partsMetadata != null) setPartsMetadata(ByteString.copyFrom(partsMetadata)) + } + .build() + + @Test + fun `dispatches valid RPC to handler`() { + val rpc = buildRpc() + + adapter.onIncomingRpc(topic, peer1, rpc) + + assertThat(capturedCalls).hasSize(1) + assertThat(capturedCalls[0].from).isEqualTo(peer1) + assertThat(capturedCalls[0].rpc).isEqualTo(rpc) + } + + @Test + fun `peerStates map is empty on first RPC for a fresh group`() { + adapter.onIncomingRpc(topic, peer1, buildRpc()) + + assertThat(capturedCalls[0].peerStates).isEmpty() + } + + @Test + fun `second RPC for the same group reuses the same peerStates object`() { + adapter.onIncomingRpc(topic, peer1, buildRpc()) + adapter.onIncomingRpc(topic, peer2, buildRpc()) + + assertThat(capturedCalls).hasSize(2) + // Both calls receive the same live GroupState.peerStates reference + assertThat(capturedCalls[0].peerStates).isSameAs(capturedCalls[1].peerStates) + } + + @Test + fun `optional partialMessage and partsMetadata are forwarded to handler`() { + val rpc = buildRpc( + partialMessage = byteArrayOf(1, 2, 3), + partsMetadata = byteArrayOf(0xFF.toByte()) + ) + + adapter.onIncomingRpc(topic, peer1, rpc) + + assertThat(capturedCalls[0].rpc.partialMessage.toByteArray()).isEqualTo(byteArrayOf(1, 2, 3)) + assertThat(capturedCalls[0].rpc.partsMetadata.toByteArray()).isEqualTo(byteArrayOf(0xFF.toByte())) + } + + @Test + fun `handler not called when per-topic DoS cap is exceeded`() { + val store = PartialGroupStateStore( + groupTtlHeartbeats = 3, + peerInitiatedGroupLimitPerTopic = 1 + ) + val capped = PartialMessagesAdapterImpl( + handler = makeHandler(), + stateStore = store, + feedback = NopPartialMessagesFeedback + ) + + capped.onIncomingRpc(topic, peer1, buildRpc(groupId = "g1".toByteArray())) + capped.onIncomingRpc(topic, peer1, buildRpc(groupId = "g2".toByteArray())) + + assertThat(capturedCalls).hasSize(1) + } + + @Test + fun `handler not called when per-peer DoS cap is exceeded`() { + val store = PartialGroupStateStore( + groupTtlHeartbeats = 3, + peerInitiatedGroupLimitPerTopicPerPeer = 1 + ) + val capped = PartialMessagesAdapterImpl( + handler = makeHandler(), + stateStore = store, + feedback = NopPartialMessagesFeedback + ) + + capped.onIncomingRpc(topic, peer1, buildRpc(groupId = "g1".toByteArray())) + capped.onIncomingRpc(topic, peer1, buildRpc(groupId = "g2".toByteArray())) + + assertThat(capturedCalls).hasSize(1) + } + + @Test + fun `different topics create independent groups`() { + adapter.onIncomingRpc("topic-a", peer1, buildRpc(topicId = "topic-a")) + adapter.onIncomingRpc("topic-b", peer1, buildRpc(topicId = "topic-b")) + + assertThat(capturedCalls).hasSize(2) + assertThat(capturedCalls[0].peerStates).isNotSameAs(capturedCalls[1].peerStates) + } + + // ---- publishPartial ---- + + @Test + fun `publishPartial enqueues RPC for peer that requests partial`() { + val enqueued = mutableListOf>() + val payload = byteArrayOf(1, 2, 3) + val meta = byteArrayOf(0xAA.toByte()) + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peer1 to PublishAction(partialMessage = payload, partsMetadata = meta)) + } + + adapter.publishPartial( + topic = topic, + groupId = groupIdBytes.toGroupId(), + actionsFn = actionsFn, + peerRequestsPartial = { true }, + enqueueFn = { p, pm, meta2 -> enqueued += Triple(p, pm, meta2) } + ) + + assertThat(enqueued).hasSize(1) + assertThat(enqueued[0].first).isEqualTo(peer1) + assertThat(enqueued[0].second).isEqualTo(payload) + assertThat(enqueued[0].third).isEqualTo(meta) + } + + @Test + fun `publishPartial omits partialMessage when peerRequestsPartial is false`() { + val enqueued = mutableListOf>() + val payload = byteArrayOf(1, 2, 3) + val meta = byteArrayOf(0xAA.toByte()) + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peer1 to PublishAction(partialMessage = payload, partsMetadata = meta)) + } + + adapter.publishPartial( + topic = topic, + groupId = groupIdBytes.toGroupId(), + actionsFn = actionsFn, + peerRequestsPartial = { false }, + enqueueFn = { p, pm, meta2 -> enqueued += Triple(p, pm, meta2) } + ) + + assertThat(enqueued).hasSize(1) + assertThat(enqueued[0].second).isNull() + assertThat(enqueued[0].third).isEqualTo(meta) + } + + @Test + fun `publishPartial skips peer when action contains an error`() { + val enqueued = mutableListOf() + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peer1 to PublishAction(error = RuntimeException("oops"))) + } + + adapter.publishPartial( + topic = topic, + groupId = groupIdBytes.toGroupId(), + actionsFn = actionsFn, + peerRequestsPartial = { true }, + enqueueFn = { p, _, _ -> enqueued += p } + ) + + assertThat(enqueued).isEmpty() + } + + @Test + fun `publishPartial does not call enqueueFn when both partialMessage and partsMetadata are null`() { + val enqueued = mutableListOf() + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peer1 to PublishAction()) + } + + adapter.publishPartial( + topic = topic, + groupId = groupIdBytes.toGroupId(), + actionsFn = actionsFn, + peerRequestsPartial = { true }, + enqueueFn = { p, _, _ -> enqueued += p } + ) + + assertThat(enqueued).isEmpty() + } + + @Test + fun `publishPartial stores nextPeerState in group`() { + val actionsFn = PublishActionsFn { _, _ -> + sequenceOf(peer1 to PublishAction(partsMetadata = byteArrayOf(1), nextPeerState = "state-for-peer1")) + } + + adapter.publishPartial( + topic = topic, + groupId = groupIdBytes.toGroupId(), + actionsFn = actionsFn, + peerRequestsPartial = { true }, + enqueueFn = { _, _, _ -> } + ) + + val group = adapter.stateStore.getGroup(topic, groupIdBytes.toGroupId()) + assertThat(group?.peerStates?.get(peer1)).isEqualTo("state-for-peer1") + } + + @Test + fun `publishPartial provides peerRequestsPartial predicate to decide`() { + val predicateCapture = mutableListOf() + val actionsFn = PublishActionsFn { _, peerRequestsPartial -> + predicateCapture += peerRequestsPartial(peer1) + emptySequence() + } + + adapter.publishPartial( + topic = topic, + groupId = groupIdBytes.toGroupId(), + actionsFn = actionsFn, + peerRequestsPartial = { it == peer1 }, + enqueueFn = { _, _, _ -> } + ) + + assertThat(predicateCapture).containsExactly(true) + } +} diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt index 2acc1569d..8256025b4 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouter.kt @@ -24,7 +24,8 @@ class SimGossipRouter( seenMessages: SeenCache>, messageValidator: PubsubRouterMessageValidator, val serializeToBytes: Boolean, - additionalHeartbeatDelay: Duration + additionalHeartbeatDelay: Duration, + gossipExtensionsConfig: GossipExtensionsConfig = GossipExtensionsConfig(), ) : GossipRouter( params, scoreParams, @@ -33,7 +34,7 @@ class SimGossipRouter( name, mCache, score, - gossipExtensionsConfig = GossipExtensionsConfig(), + gossipExtensionsConfig = gossipExtensionsConfig, subscriptionTopicSubscriptionFilter, protocol, executor, diff --git a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt index 7096b2fdc..70bf7432d 100644 --- a/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt +++ b/tools/simulator/src/main/kotlin/io/libp2p/simulate/gossip/router/SimGossipRouterBuilder.kt @@ -27,7 +27,8 @@ class SimGossipRouterBuilder : GossipRouterBuilder() { seenMessages = seenCache, messageValidator = messageValidator, serializeToBytes = serializeMessagesToBytes, - additionalHeartbeatDelay = additionalHeartbeatDelay + additionalHeartbeatDelay = additionalHeartbeatDelay, + gossipExtensionsConfig = buildGossipExtensionsConfig(), ) router.eventBroadcaster.listeners += gossipRouterEventListeners diff --git a/tools/simulator/src/test/kotlin/io/libp2p/simulate/gossip/PartialMessagesSimTest.kt b/tools/simulator/src/test/kotlin/io/libp2p/simulate/gossip/PartialMessagesSimTest.kt new file mode 100644 index 000000000..9afe39cae --- /dev/null +++ b/tools/simulator/src/test/kotlin/io/libp2p/simulate/gossip/PartialMessagesSimTest.kt @@ -0,0 +1,154 @@ +package io.libp2p.simulate.gossip + +import io.libp2p.core.PeerId +import io.libp2p.core.pubsub.Topic +import io.libp2p.pubsub.PubsubProtocol +import io.libp2p.pubsub.gossip.GossipExtension +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesHandler +import io.libp2p.pubsub.gossip.partialmessages.PartialMessagesPeerFeedback +import io.libp2p.simulate.TopologyGraph +import io.libp2p.simulate.gossip.router.SimGossipRouterBuilder +import io.libp2p.simulate.topology.asFixedTopology +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import pubsub.pb.Rpc +import kotlin.time.Duration.Companion.seconds + +/** + * Simulator scenario for Step 10: mixed partial + non-partial nodes on the same topic. + * + * Network topology (star, peer 0 is the hub): + * 0 ↔ 1 (peer 1 is partial-capable) + * 0 ↔ 2 (peer 2 is non-partial) + * 0 ↔ 3 (peer 3 is non-partial) + * + * Peers 0 and 1 have [GossipExtension.PARTIAL_MESSAGES] enabled with + * `requestsPartial = true` and `supportsSendingPartial = true` on the shared topic. + * Peers 2 and 3 use Gossip v1.3 without the extension — they send + * ControlExtensions with `partialMessages = false`. + * + * All peers use Gossip v1.3 (required for ControlExtensions / SubOpts flag wire support). + * + * Two routing properties are exercised: + * 1. Full messages published by a partial-capable peer are suppressed for the partial peer (1) + * but reach the non-partial peers (2, 3). + * 2. Full messages published by a non-partial peer are received by the partial-capable hub (0) + * and forwarded to the non-partial peer (3), but NOT to the partial peer (1) — because the + * partial-capable hub suppresses forwarding to partial-requesting peers in `broadcastInbound`. + */ +class PartialMessagesSimTest { + + private val topic = Topic(BlocksTopic) + + private val PARTIAL_PEER_COUNT = 2 + private val TOTAL_PEERS = 4 + + // D=3 so all 3 connected peers fit comfortably in the mesh. + private val gossipParams = Eth2DefaultGossipParams.copy(D = 3, DLow = 1, DHigh = 3, DOut = 0) + private val gossipScoreParams = Eth2DefaultScoreParams + + private val simConfig = GossipSimConfig( + totalPeers = TOTAL_PEERS, + topics = listOf(topic), + topology = TopologyGraph.customTopology( + 0 to 1, + 0 to 2, + 0 to 3, + ).asFixedTopology(), + warmUpDelay = 10.seconds, + ) + + private val nopHandler = object : PartialMessagesHandler { + override fun onIncomingRpc( + from: PeerId, + peerStates: Map, + rpc: Rpc.PartialMessagesExtension, + feedback: PartialMessagesPeerFeedback, + ) {} + + override fun onEmitGossip( + topic: io.libp2p.pubsub.Topic, + groupId: ByteArray, + gossipPeers: Collection, + peerStates: Map, + feedback: PartialMessagesPeerFeedback, + ) {} + } + + private fun buildSimulation(): GossipSimulation { + val routerFactory: GossipRouterBuilderFactory = { peerIndex -> + SimGossipRouterBuilder().also { + it.params = gossipParams + it.scoreParams = gossipScoreParams + // All peers use v1.3 so they exchange ControlExtensions. + it.protocol = PubsubProtocol.Gossip_V_1_3 + if (peerIndex < PARTIAL_PEER_COUNT) { + it.enabledGossipExtensions(GossipExtension.PARTIAL_MESSAGES) + it.partialMessagesHandler = nopHandler + } + } + } + + val simNetwork = GossipSimNetwork(simConfig, routerFactory) { peerIndex, peer -> + if (peerIndex < PARTIAL_PEER_COUNT) { + // Set partial flags before subscribe so they are included in the SubOpts + // sent to peers on activation (onPeerActive → enqueueSubscribe reads them). + peer.router.setTopicPartialFlags( + topic.topic, + requestsPartial = true, + supportsSendingPartial = true, + ) + } + } + + simNetwork.createAllPeers() + simNetwork.connectAllPeers() + return GossipSimulation(simConfig, simNetwork) + } + + // ── Test 1: full-message suppression ───────────────────────────────────── + + @Test + fun `full messages from partial node are suppressed for partial peer, delivered to non-partial peers`() { + val simulation = buildSimulation() + + // Peer 0 (partial, supportsSendingPartial=true) publishes a full gossip message. + simulation.publishMessage(srcPeer = 0) + simulation.forwardTime(2.seconds) + + val result = simulation.gatherPubDeliveryStats() + val recipientIds = result.deliveries.map { it.toPeer.simPeerId }.toSet() + + // Non-partial peers MUST receive the full message. + assertThat(recipientIds).contains(2, 3) + + // Peer 1 (partial, requestsPartial=true, connected only to peer 0) MUST NOT + // receive the full message — peer 0 suppresses it. + assertThat(recipientIds).doesNotContain(1) + } + + // ── Test 2: non-partial propagation ────────────────────────────────────── + + @Test + fun `full messages from non-partial node propagate to non-partial peers, not to partial peer`() { + val simulation = buildSimulation() + + // Peer 2 (non-partial) publishes a full gossip message. + simulation.publishMessage(srcPeer = 2) + simulation.forwardTime(2.seconds) + + val result = simulation.gatherPubDeliveryStats() + val recipientIds = result.deliveries.map { it.toPeer.simPeerId }.toSet() + + // Peer 0 (partial-capable hub, directly connected to peer 2) MUST receive the message. + // Non-partial senders cannot honour partial requests; they send full messages unconditionally. + assertThat(recipientIds).contains(0) + + // Peer 3 (non-partial) MUST receive the message forwarded by peer 0. + assertThat(recipientIds).contains(3) + + // Peer 1 (partial, connected only to peer 0) MUST NOT receive the full message + // — peer 0 suppresses forwarding to partial-requesting peers in broadcastInbound. + assertThat(recipientIds).doesNotContain(1) + } +}