From af93eae19900b456a1834149b69f2cdd9c4b42be Mon Sep 17 00:00:00 2001 From: darshankabariya Date: Sat, 11 Apr 2026 23:46:51 +0530 Subject: [PATCH] feat: upgrade testbench and claude.md file for better context --- sds.nim | 98 ++++-- sds/protobuf.nim | 6 + sds/sds_utils.nim | 21 +- sds/types/channel_context.nim | 3 + sds/types/sds_message.nim | 3 + tests/test_bloom | Bin 419624 -> 419592 bytes tests/test_reliability.nim | 616 ++++++++++++++++++++++++++++++++++ 7 files changed, 702 insertions(+), 45 deletions(-) diff --git a/sds.nim b/sds.nim index d9c4b82..c4a1482 100644 --- a/sds.nim +++ b/sds.nim @@ -107,6 +107,7 @@ proc wrapOutgoingMessage*( channelId = channelId, content = message, bloomFilter = bfResult.get(), + senderId = rm.participantId, repairRequest = repairReqs, ) @@ -117,7 +118,16 @@ proc wrapOutgoingMessage*( channel.bloomFilter.add(msg.messageId) rm.addToHistory(msg.messageId, channelId) - return serializeMessage(msg) + # SDS-R: record sender for future causal-history entries + if rm.participantId.len > 0: + channel.messageSenders[msg.messageId] = rm.participantId + + let serialized = serializeMessage(msg) + if serialized.isOk(): + # SDS-R: cache serialized bytes so we can serve our own message on repair + if channel.messageCache.len < rm.config.maxMessageHistory: + channel.messageCache[msg.messageId] = serialized.get() + return serialized except Exception: error "Failed to wrap message", channelId = channelId, msg = getCurrentExceptionMsg() @@ -177,6 +187,11 @@ proc unwrapReceivedMessage*( let channel = rm.getOrCreateChannel(channelId) + # SDS-R: opportunistic repair-buffer cleanup — applies to duplicates too, + # so rebroadcasts cancel redundant responses on peers that already have the message. + channel.outgoingRepairBuffer.del(msg.messageId) + channel.incomingRepairBuffer.del(msg.messageId) + if msg.messageId in channel.messageHistory: return ok((msg.content, @[], channelId)) @@ -185,14 +200,14 @@ proc unwrapReceivedMessage*( rm.updateLamportTimestamp(msg.lamportTimestamp, channelId) rm.reviewAckStatus(msg) - # SDS-R: remove this message from repair buffers (dependency now met) - channel.outgoingRepairBuffer.del(msg.messageId) - channel.incomingRepairBuffer.del(msg.messageId) - # SDS-R: cache the raw message for potential repair responses if channel.messageCache.len < rm.config.maxMessageHistory: channel.messageCache[msg.messageId] = message + # SDS-R: record sender so our future causal-history entries carry it + if msg.senderId.len > 0: + channel.messageSenders[msg.messageId] = msg.senderId + # SDS-R: process incoming repair requests from this message let now = getTime() for repairEntry in msg.repairRequest: @@ -228,6 +243,10 @@ proc unwrapReceivedMessage*( IncomingMessage.init(message = msg, missingDeps = initHashSet[SdsMessageID]()) else: rm.addToHistory(msg.messageId, channelId) + # Unblock any buffered messages that were waiting on this one. + for pendingId, entry in channel.incomingBuffer: + if msg.messageId in entry.missingDeps: + channel.incomingBuffer[pendingId].missingDeps.excl(msg.messageId) rm.processIncomingBuffer(channelId) if not rm.onMessageReady.isNil(): rm.onMessageReady(msg.messageId, channelId) @@ -361,43 +380,47 @@ proc periodicSyncMessage( error "Error in periodic sync", msg = getCurrentExceptionMsg() await sleepAsync(chronos.seconds(rm.config.syncMessageInterval.inSeconds)) +proc runRepairSweep*(rm: ReliabilityManager) {.gcsafe, raises: [].} = + ## SDS-R: Runs a single pass of the repair sweep. + ## - Incoming: fires onRepairReady for expired T_resp entries and removes them + ## - Outgoing: drops entries past T_max window + ## Exposed so it can be driven directly in tests; also invoked by periodicRepairSweep. + try: + let now = getTime() + for channelId, channel in rm.channels: + try: + # Check incoming repair buffer for expired T_resp (time to rebroadcast) + var toRebroadcast: seq[SdsMessageID] = @[] + for msgId, entry in channel.incomingRepairBuffer: + if now >= entry.tResp: + toRebroadcast.add(msgId) + + for msgId in toRebroadcast: + let entry = channel.incomingRepairBuffer[msgId] + channel.incomingRepairBuffer.del(msgId) + if not rm.onRepairReady.isNil(): + rm.onRepairReady(entry.cachedMessage, channelId) + + # Drop expired outgoing repair entries past T_max + var toRemove: seq[SdsMessageID] = @[] + let tMaxDuration = rm.config.repairTMax + for msgId, entry in channel.outgoingRepairBuffer: + if now - entry.tReq > tMaxDuration: + toRemove.add(msgId) + for msgId in toRemove: + channel.outgoingRepairBuffer.del(msgId) + except Exception: + error "Error in repair sweep for channel", + channelId = channelId, msg = getCurrentExceptionMsg() + except Exception: + error "Error in repair sweep", msg = getCurrentExceptionMsg() + proc periodicRepairSweep( rm: ReliabilityManager ) {.async: (raises: [CancelledError]), gcsafe.} = ## SDS-R: Periodically checks repair buffers for expired entries. - ## - Incoming: fires onRepairReady for expired T_resp entries - ## - Outgoing: drops entries past T_max while true: - try: - let now = getTime() - for channelId, channel in rm.channels: - try: - # Check incoming repair buffer for expired T_resp (time to rebroadcast) - var toRebroadcast: seq[SdsMessageID] = @[] - for msgId, entry in channel.incomingRepairBuffer: - if now >= entry.tResp: - toRebroadcast.add(msgId) - - for msgId in toRebroadcast: - let entry = channel.incomingRepairBuffer[msgId] - channel.incomingRepairBuffer.del(msgId) - if not rm.onRepairReady.isNil(): - rm.onRepairReady(entry.cachedMessage, channelId) - - # Drop expired outgoing repair entries past T_max - var toRemove: seq[SdsMessageID] = @[] - let tMaxDuration = rm.config.repairTMax - for msgId, entry in channel.outgoingRepairBuffer: - if now - entry.tReq > tMaxDuration: - toRemove.add(msgId) - for msgId in toRemove: - channel.outgoingRepairBuffer.del(msgId) - except Exception: - error "Error in repair sweep for channel", - channelId = channelId, msg = getCurrentExceptionMsg() - except Exception: - error "Error in periodic repair sweep", msg = getCurrentExceptionMsg() - + rm.runRepairSweep() await sleepAsync(chronos.milliseconds(rm.config.repairSweepInterval.inMilliseconds)) proc startPeriodicTasks*(rm: ReliabilityManager) = @@ -418,6 +441,7 @@ proc resetReliabilityManager*(rm: ReliabilityManager): Result[void, ReliabilityE channel.outgoingRepairBuffer.clear() channel.incomingRepairBuffer.clear() channel.messageCache.clear() + channel.messageSenders.clear() channel.bloomFilter = RollingBloomFilter.init(rm.config.bloomFilterCapacity, rm.config.bloomFilterErrorRate) rm.channels.clear() diff --git a/sds/protobuf.nim b/sds/protobuf.nim index 63830c7..24a95d7 100644 --- a/sds/protobuf.nim +++ b/sds/protobuf.nim @@ -37,6 +37,9 @@ proc encode*(msg: SdsMessage): ProtoBuffer = pb.write(5, msg.content) pb.write(6, msg.bloomFilter) + if msg.senderId.len > 0: + pb.write(7, msg.senderId) + for entry in msg.repairRequest: let entryPb = encodeHistoryEntry(entry) pb.write(13, entryPb.buffer) @@ -81,6 +84,9 @@ proc decode*(T: type SdsMessage, buffer: seq[byte]): ProtobufResult[T] = if not ?pb.getField(6, msg.bloomFilter): msg.bloomFilter = @[] # Empty if not present + # SDS-R: decode senderId (field 7, optional) + discard pb.getField(7, msg.senderId) + # SDS-R: decode repair request (field 13, optional) var repairBuffers: seq[seq[byte]] if pb.getRepeatedField(13, repairBuffers).isOk(): diff --git a/sds/sds_utils.nim b/sds/sds_utils.nim index f979b3e..7e4eba6 100644 --- a/sds/sds_utils.nim +++ b/sds/sds_utils.nim @@ -25,6 +25,7 @@ proc cleanup*(rm: ReliabilityManager) {.raises: [].} = channel.outgoingRepairBuffer.clear() channel.incomingRepairBuffer.clear() channel.messageCache.clear() + channel.messageSenders.clear() rm.channels.clear() except Exception: error "Error during cleanup", error = getCurrentExceptionMsg() @@ -128,18 +129,21 @@ proc isInResponseGroup*( proc getRecentHistoryEntries*( rm: ReliabilityManager, n: int, channelId: SdsChannelID ): seq[HistoryEntry] = + ## Get recent history entries for sending in causal history. + ## Populates retrieval hints and senderId (SDS-R) for each entry. try: if channelId in rm.channels: let channel = rm.channels[channelId] let recentMessageIds = channel.messageHistory[max(0, channel.messageHistory.len - n) .. ^1] - if rm.onRetrievalHint.isNil(): - return toCausalHistory(recentMessageIds) - else: - var entries: seq[HistoryEntry] = @[] - for msgId in recentMessageIds: - let hint = rm.onRetrievalHint(msgId) - entries.add(newHistoryEntry(msgId, hint)) - return entries + var entries: seq[HistoryEntry] = @[] + for msgId in recentMessageIds: + var entry = HistoryEntry(messageId: msgId) + if not rm.onRetrievalHint.isNil(): + entry.retrievalHint = rm.onRetrievalHint(msgId) + if msgId in channel.messageSenders: + entry.senderId = channel.messageSenders[msgId] + entries.add(entry) + return entries else: return @[] except Exception: @@ -246,6 +250,7 @@ proc removeChannel*( channel.outgoingRepairBuffer.clear() channel.incomingRepairBuffer.clear() channel.messageCache.clear() + channel.messageSenders.clear() rm.channels.del(channelId) return ok() except Exception: diff --git a/sds/types/channel_context.nim b/sds/types/channel_context.nim index cec11dc..2f61584 100644 --- a/sds/types/channel_context.nim +++ b/sds/types/channel_context.nim @@ -19,6 +19,8 @@ type ChannelContext* = ref object incomingRepairBuffer*: Table[SdsMessageID, IncomingRepairEntry] messageCache*: Table[SdsMessageID, seq[byte]] ## Cached serialized messages for repair responses + messageSenders*: Table[SdsMessageID, SdsParticipantID] + ## SDS-R: msgId -> original sender, used to populate causal-history senderId proc new*(T: type ChannelContext, bloomFilter: RollingBloomFilter): T = return T( @@ -30,4 +32,5 @@ proc new*(T: type ChannelContext, bloomFilter: RollingBloomFilter): T = outgoingRepairBuffer: initTable[SdsMessageID, OutgoingRepairEntry](), incomingRepairBuffer: initTable[SdsMessageID, IncomingRepairEntry](), messageCache: initTable[SdsMessageID, seq[byte]](), + messageSenders: initTable[SdsMessageID, SdsParticipantID](), ) diff --git a/sds/types/sds_message.nim b/sds/types/sds_message.nim index b50380c..6ab7a4f 100644 --- a/sds/types/sds_message.nim +++ b/sds/types/sds_message.nim @@ -9,6 +9,7 @@ type SdsMessage* = object channelId*: SdsChannelID content*: seq[byte] bloomFilter*: seq[byte] + senderId*: SdsParticipantID ## SDS-R: original sender's participant ID repairRequest*: seq[HistoryEntry] ## Capped list of missing entries requesting repair (SDS-R) @@ -20,6 +21,7 @@ proc init*( channelId: SdsChannelID, content: seq[byte], bloomFilter: seq[byte], + senderId: SdsParticipantID = "", repairRequest: seq[HistoryEntry] = @[], ): T = return T( @@ -29,5 +31,6 @@ proc init*( channelId: channelId, content: content, bloomFilter: bloomFilter, + senderId: senderId, repairRequest: repairRequest, ) diff --git a/tests/test_bloom b/tests/test_bloom index cf17bfaf8bba21f383239a1ed49bae2bffb889fd..f4776c4b0cf87a2bdc3c03f5c34db079c02412e5 100755 GIT binary patch delta 40228 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 delta 40314 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 Vxh2r!wyX7+K|8h6q{We{{{u@3TVenJ diff --git a/tests/test_reliability.nim b/tests/test_reliability.nim index aa0eb06..7f738c5 100644 --- a/tests/test_reliability.nim +++ b/tests/test_reliability.nim @@ -1015,3 +1015,619 @@ suite "SDS-R: Protobuf Roundtrip": check: decoded.repairRequest.len == 0 decoded.causalHistory[0].senderId == "" + + test "SdsMessage.senderId roundtrips through protobuf": + let msg = SdsMessage( + messageId: "m1", + lamportTimestamp: 1, + causalHistory: @[], + channelId: "ch1", + content: @[byte(1)], + bloomFilter: @[], + senderId: "alice", + ) + let decoded = deserializeMessage(serializeMessage(msg).get()).get() + check decoded.senderId == "alice" + +# --------------------------------------------------------------------------- +# SDS-R Phase 2 tests: edge cases, lifecycle, sweep, and multi-participant flows +# --------------------------------------------------------------------------- + +suite "SDS-R: Edge Cases and Defensive Branches": + test "computeTReq returns tMin when range is degenerate": + let tMin = initDuration(seconds = 30) + # tMax == tMin + let d1 = computeTReq("p", "m", tMin, tMin) + check d1 == tMin + # tMax < tMin (rangeMs < 0) + let d2 = computeTReq("p", "m", tMin, initDuration(seconds = 10)) + check d2 == tMin + + test "computeTResp returns 0 when tMax is 0": + let d = computeTResp("p", "other", "m", initDuration(milliseconds = 0)) + check d.inMilliseconds == 0 + + test "computeTResp always stays within [0, tMax)": + # Adversarial sweep — result must never wrap negative nor exceed tMax + let tMax = initDuration(seconds = 300) + for i in 0 ..< 500: + let d = computeTResp( + "participant-" & $i, "sender-" & $(i * 13), "msg-" & $(i * 31), tMax + ) + check: + d.inMilliseconds >= 0 + d.inMilliseconds < tMax.inMilliseconds + + test "isInResponseGroup returns true for non-positive numGroups": + check isInResponseGroup("p", "sender", "m", 0) == true + check isInResponseGroup("p", "sender", "m", -1) == true + + test "computeTReq bounds across many random inputs": + let tMin = initDuration(seconds = 30) + let tMax = initDuration(seconds = 300) + for i in 0 ..< 200: + let d = computeTReq("p-" & $i, "m-" & $i, tMin, tMax) + check: + d.inMilliseconds >= tMin.inMilliseconds + d.inMilliseconds < tMax.inMilliseconds + + test "response group distribution is roughly uniform": + # With numGroups=10, ~10% of random participants should share sender's group. + const numGroups = 10 + const totalParticipants = 1000 + let senderId = "alice" + let msgId = "msg-xyz" + var sameGroup = 0 + for i in 0 ..< totalParticipants: + if isInResponseGroup("participant-" & $i, senderId, msgId, numGroups): + sameGroup += 1 + # Expected ~100 (1/N), allow [50, 200] band for hash quirks + check: + sameGroup >= 50 + sameGroup <= 200 + + test "computeTResp monotonicity: self always fastest": + # The original sender (distance=0) must always be first to respond. + let tMax = initDuration(seconds = 300) + let selfD = computeTResp("alice", "alice", "msg-xyz", tMax) + check selfD.inMilliseconds == 0 + for i in 0 ..< 50: + let other = computeTResp("other-" & $i, "alice", "msg-xyz", tMax) + check other.inMilliseconds >= selfD.inMilliseconds + +suite "SDS-R: Lifecycle and State": + test "empty participantId disables outgoing repair creation": + let rm = newReliabilityManager().get() # empty participantId + defer: rm.cleanup() + check rm.ensureChannel(testChannel).isOk() + + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + ) + + let msg = SdsMessage( + messageId: "m2", + lamportTimestamp: 2, + causalHistory: @[HistoryEntry(messageId: "m1-missing", senderId: "alice")], + channelId: testChannel, + content: @[byte(2)], + bloomFilter: @[], + ) + discard rm.unwrapReceivedMessage(serializeMessage(msg).get()) + check rm.channels[testChannel].outgoingRepairBuffer.len == 0 + + test "empty senderId in incoming repair request is ignored": + let rm = newReliabilityManager(participantId = "bob").get() + defer: rm.cleanup() + check rm.ensureChannel(testChannel).isOk() + let channel = rm.channels[testChannel] + channel.messageCache["m-wanted"] = @[byte(99), 99, 99] + + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + ) + + let msg = SdsMessage( + messageId: "req-msg", + lamportTimestamp: 5, + causalHistory: @[], + channelId: testChannel, + content: @[byte(1)], + bloomFilter: @[], + repairRequest: @[HistoryEntry(messageId: "m-wanted", senderId: "")], + ) + discard rm.unwrapReceivedMessage(serializeMessage(msg).get()) + check "m-wanted" notin channel.incomingRepairBuffer + + test "wrapOutgoingMessage caches bytes and records sender": + # Proves Bug 1 is fixed — the original sender can serve her own message. + let rm = newReliabilityManager(participantId = "alice").get() + defer: rm.cleanup() + check rm.ensureChannel(testChannel).isOk() + + discard rm.wrapOutgoingMessage(@[byte(1), 2, 3], "m1", testChannel) + let channel = rm.channels[testChannel] + check: + "m1" in channel.messageCache + channel.messageCache["m1"].len > 0 + "m1" in channel.messageSenders + channel.messageSenders["m1"] == "alice" + + test "getRecentHistoryEntries carries senderId for own messages": + let rm = newReliabilityManager(participantId = "alice").get() + defer: rm.cleanup() + check rm.ensureChannel(testChannel).isOk() + + discard rm.wrapOutgoingMessage(@[byte(1)], "m1", testChannel) + discard rm.wrapOutgoingMessage(@[byte(2)], "m2", testChannel) + let entries = rm.getRecentHistoryEntries(10, testChannel) + check: + entries.len == 2 + entries[0].senderId == "alice" + entries[1].senderId == "alice" + + test "resetReliabilityManager clears all SDS-R state": + let rm = newReliabilityManager(participantId = "alice").get() + defer: rm.cleanup() + check rm.ensureChannel(testChannel).isOk() + let channel = rm.channels[testChannel] + + channel.outgoingRepairBuffer["a"] = OutgoingRepairEntry( + entry: HistoryEntry(messageId: "a", senderId: "x"), + tReq: getTime(), + ) + channel.incomingRepairBuffer["b"] = IncomingRepairEntry( + entry: HistoryEntry(messageId: "b", senderId: "y"), + cachedMessage: @[byte(1)], + tResp: getTime(), + ) + channel.messageCache["c"] = @[byte(2)] + channel.messageSenders["c"] = "someone" + + check rm.resetReliabilityManager().isOk() + check rm.ensureChannel(testChannel).isOk() + let ch2 = rm.channels[testChannel] + check: + ch2.outgoingRepairBuffer.len == 0 + ch2.incomingRepairBuffer.len == 0 + ch2.messageCache.len == 0 + ch2.messageSenders.len == 0 + + test "SDS-R state is isolated per channel": + let rm = newReliabilityManager(participantId = "alice").get() + defer: rm.cleanup() + check rm.ensureChannel("ch-A").isOk() + check rm.ensureChannel("ch-B").isOk() + + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + ) + + let msg = SdsMessage( + messageId: "m2", + lamportTimestamp: 2, + causalHistory: @[HistoryEntry(messageId: "m1-missing", senderId: "bob")], + channelId: "ch-A", + content: @[byte(2)], + bloomFilter: @[], + ) + discard rm.unwrapReceivedMessage(serializeMessage(msg).get()) + check: + rm.channels["ch-A"].outgoingRepairBuffer.len == 1 + rm.channels["ch-B"].outgoingRepairBuffer.len == 0 + + test "duplicate message arrival cancels pending incoming repair entry": + # Covers the dedup-before-cleanup fix: a rebroadcast arriving at a peer who + # already has the message must clear that peer's incomingRepairBuffer entry. + let rm = newReliabilityManager(participantId = "carol").get() + defer: rm.cleanup() + check rm.ensureChannel(testChannel).isOk() + let channel = rm.channels[testChannel] + + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + ) + + # Carol already has M1 in history and has a pending incomingRepairBuffer entry + channel.messageHistory.add("m1") + channel.incomingRepairBuffer["m1"] = IncomingRepairEntry( + entry: HistoryEntry(messageId: "m1", senderId: "alice"), + cachedMessage: @[byte(1)], + tResp: getTime() + initDuration(seconds = 10), + ) + + # A rebroadcast of M1 arrives + let msg = SdsMessage( + messageId: "m1", + lamportTimestamp: 1, + causalHistory: @[], + channelId: testChannel, + content: @[byte(1)], + bloomFilter: @[], + senderId: "alice", + ) + discard rm.unwrapReceivedMessage(serializeMessage(msg).get()) + check "m1" notin channel.incomingRepairBuffer + +suite "SDS-R: Repair Sweep": + var rm: ReliabilityManager + + setup: + rm = newReliabilityManager(participantId = "bob").get() + check rm.ensureChannel(testChannel).isOk() + + teardown: + if not rm.isNil: + rm.cleanup() + + test "runRepairSweep fires onRepairReady for expired tResp": + var fireCount = 0 + var firstBytes: seq[byte] = @[] + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + onRepairReady = proc(bytes: seq[byte], ch: SdsChannelID) {.gcsafe.} = + {.cast(gcsafe).}: + fireCount += 1 + if fireCount == 1: + firstBytes = bytes, + ) + + let channel = rm.channels[testChannel] + channel.incomingRepairBuffer["m-ready"] = IncomingRepairEntry( + entry: HistoryEntry(messageId: "m-ready", senderId: "alice"), + cachedMessage: @[byte(1), 2, 3], + tResp: getTime() - initDuration(seconds = 1), # expired + ) + channel.incomingRepairBuffer["m-not-ready"] = IncomingRepairEntry( + entry: HistoryEntry(messageId: "m-not-ready", senderId: "alice"), + cachedMessage: @[byte(9), 9, 9], + tResp: getTime() + initDuration(minutes = 10), # far future + ) + + rm.runRepairSweep() + + check: + fireCount == 1 + firstBytes == @[byte(1), 2, 3] + "m-ready" notin channel.incomingRepairBuffer + "m-not-ready" in channel.incomingRepairBuffer + + test "runRepairSweep drops outgoing entries past T_max window": + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + ) + + let channel = rm.channels[testChannel] + let tMax = rm.config.repairTMax + channel.outgoingRepairBuffer["m-stale"] = OutgoingRepairEntry( + entry: HistoryEntry(messageId: "m-stale", senderId: "alice"), + tReq: getTime() - (tMax + tMax), # now - 2*T_max, past drop window + ) + channel.outgoingRepairBuffer["m-fresh"] = OutgoingRepairEntry( + entry: HistoryEntry(messageId: "m-fresh", senderId: "alice"), + tReq: getTime(), + ) + + rm.runRepairSweep() + + check: + "m-stale" notin channel.outgoingRepairBuffer + "m-fresh" in channel.outgoingRepairBuffer + + test "runRepairSweep no-op when buffers are empty": + var fireCount = 0 + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + onRepairReady = proc(bytes: seq[byte], ch: SdsChannelID) {.gcsafe.} = + fireCount += 1, + ) + rm.runRepairSweep() + check fireCount == 0 + +# --- Multi-participant in-process bus for integration tests --------------- + +type + TestBus = ref object + peers: OrderedTable[SdsParticipantID, ReliabilityManager] + delivered: Table[SdsParticipantID, seq[SdsMessageID]] + # Log of raw message-ids placed on the wire, tagged with the source peer. + wireLog: seq[tuple[senderId: SdsParticipantID, messageId: SdsMessageID]] + +proc newTestBus(): TestBus = + TestBus( + peers: initOrderedTable[SdsParticipantID, ReliabilityManager](), + delivered: initTable[SdsParticipantID, seq[SdsMessageID]](), + wireLog: @[], + ) + +proc recordWire(bus: TestBus, senderId: SdsParticipantID, bytes: seq[byte]) {.gcsafe.} = + let decoded = deserializeMessage(bytes) + if decoded.isOk(): + bus.wireLog.add((senderId, decoded.get().messageId)) + +proc deliverExcept( + bus: TestBus, + senderId: SdsParticipantID, + bytes: seq[byte], + exclude: seq[SdsParticipantID], +) {.gcsafe.} = + for pid, peer in bus.peers: + if pid == senderId or pid in exclude: + continue + discard peer.unwrapReceivedMessage(bytes) + +proc addPeer( + bus: TestBus, + participantId: SdsParticipantID, + config: ReliabilityConfig = defaultConfig(), +): ReliabilityManager = + let rm = newReliabilityManager(config, participantId).get() + doAssert rm.ensureChannel(testChannel).isOk() + bus.peers[participantId] = rm + bus.delivered[participantId] = @[] + + let pid = participantId + let busRef = bus + rm.setCallbacks( + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = + {.cast(gcsafe).}: + busRef.delivered[pid].add(msgId), + proc(msgId: SdsMessageID, ch: SdsChannelID) {.gcsafe.} = discard, + proc(msgId: SdsMessageID, deps: seq[HistoryEntry], ch: SdsChannelID) {.gcsafe.} = discard, + onRepairReady = proc(bytes: seq[byte], ch: SdsChannelID) {.gcsafe.} = + {.cast(gcsafe).}: + busRef.recordWire(pid, bytes) + busRef.deliverExcept(pid, bytes, @[]), + ) + rm + +proc broadcast( + bus: TestBus, + senderId: SdsParticipantID, + content: seq[byte], + messageId: SdsMessageID, + dropAt: seq[SdsParticipantID] = @[], +) = + let rm = bus.peers[senderId] + let wrapped = rm.wrapOutgoingMessage(content, messageId, testChannel) + doAssert wrapped.isOk() + bus.recordWire(senderId, wrapped.get()) + bus.deliverExcept(senderId, wrapped.get(), dropAt) + +proc forceOutgoingExpired( + rm: ReliabilityManager, messageId: SdsMessageID +) = + ## Push a specific outgoingRepairBuffer entry's tReq into the past so the + ## next wrapOutgoingMessage will pick it up. + let channel = rm.channels[testChannel] + if messageId in channel.outgoingRepairBuffer: + channel.outgoingRepairBuffer[messageId].tReq = + getTime() - initDuration(seconds = 1) + +proc forceIncomingExpired( + rm: ReliabilityManager, messageId: SdsMessageID +) = + ## Push an incomingRepairBuffer entry's tResp into the past so runRepairSweep fires it. + let channel = rm.channels[testChannel] + if messageId in channel.incomingRepairBuffer: + channel.incomingRepairBuffer[messageId].tResp = + getTime() - initDuration(seconds = 1) + +suite "SDS-R: Multi-Participant Integration": + + test "basic single-gap repair (Alice -> Bob misses -> Carol's message triggers repair)": + let bus = newTestBus() + let alice = bus.addPeer("alice") + let bob = bus.addPeer("bob") + let carol = bus.addPeer("carol") + + # Alice sends M1, but Bob is offline for this one. + bus.broadcast("alice", @[byte(1)], "m1", dropAt = @["bob".SdsParticipantID]) + # Carol now has M1; Bob does not. + check "m1" in carol.channels[testChannel].messageHistory + check "m1" notin bob.channels[testChannel].messageHistory + + # Carol sends M2 with causal history referencing M1. + bus.broadcast("carol", @[byte(2)], "m2") + # Bob detects M1 missing and populates his outgoingRepairBuffer. + check "m1" in bob.channels[testChannel].outgoingRepairBuffer + # Bob should have buffered M2. + check "m2" in bob.channels[testChannel].incomingBuffer + check "m2" notin bus.delivered["bob"] + + # Force Bob's T_req so the next wrap attaches the repair request. + bob.forceOutgoingExpired("m1") + + # Bob sends M3 — it must carry repair_request=[M1, sender=alice]. + bus.broadcast("bob", @[byte(3)], "m3") + + # Alice received M3, saw the repair_request, cached-bypass and response-group + # checks pass, so she has an incomingRepairBuffer entry for M1 with tResp=0. + check "m1" in alice.channels[testChannel].incomingRepairBuffer + + # Force alice's tResp to past just to be safe (it's already 0 for self), + # then run her sweep. She rebroadcasts M1. + alice.forceIncomingExpired("m1") + alice.runRepairSweep() + + # Bob now has M1 and M2 delivered. + check: + "m1" in bus.delivered["bob"] + "m2" in bus.delivered["bob"] + + test "response cancellation: only one rebroadcast on the wire": + let bus = newTestBus() + let alice = bus.addPeer("alice") + let bob = bus.addPeer("bob") + let carol = bus.addPeer("carol") + + # Alice sends M1, Bob offline. + bus.broadcast("alice", @[byte(1)], "m1", dropAt = @["bob".SdsParticipantID]) + # Carol sends M2; Bob sees M1 missing. + bus.broadcast("carol", @[byte(2)], "m2") + check "m1" in bob.channels[testChannel].outgoingRepairBuffer + + # Bob requests repair. + bob.forceOutgoingExpired("m1") + bus.broadcast("bob", @[byte(3)], "m3") + + # Both Alice and Carol now have an incomingRepairBuffer entry for M1. + check: + "m1" in alice.channels[testChannel].incomingRepairBuffer + "m1" in carol.channels[testChannel].incomingRepairBuffer + + # Alice fires first (T_resp=0 for self). Her rebroadcast should cancel Carol's + # pending entry when Carol receives the rebroadcast. + alice.forceIncomingExpired("m1") + alice.runRepairSweep() + + # Carol's pending response must have been cleared by the dedup-path cleanup. + check "m1" notin carol.channels[testChannel].incomingRepairBuffer + + # Even if we now force-run Carol's sweep, nothing should fire. + let wireCountBefore = bus.wireLog.len + carol.runRepairSweep() + check bus.wireLog.len == wireCountBefore + + # Bob received exactly one rebroadcast of M1. + var m1RebroadcastCount = 0 + for entry in bus.wireLog: + if entry.messageId == "m1" and entry.senderId != "alice": + discard # only the original Alice->all broadcast had senderId="alice" + if entry.messageId == "m1": + m1RebroadcastCount += 1 + # Two "m1" entries total on wire: (1) Alice's original broadcast, (2) Alice's rebroadcast. + check m1RebroadcastCount == 2 + + test "cancellation on incoming repair request: peer drops its own pending request": + let bus = newTestBus() + let alice = bus.addPeer("alice") + let bob = bus.addPeer("bob") + let carol = bus.addPeer("carol") + + # Alice sends M1 — drop at both Bob and Carol, so both miss it. + bus.broadcast( + "alice", @[byte(1)], "m1", + dropAt = @["bob".SdsParticipantID, "carol".SdsParticipantID], + ) + # Alice sends M2 referencing M1 — both Bob and Carol see M1 missing. + bus.broadcast("alice", @[byte(2)], "m2") + check: + "m1" in bob.channels[testChannel].outgoingRepairBuffer + "m1" in carol.channels[testChannel].outgoingRepairBuffer + + # Bob's T_req fires first. He sends a repair request for M1. + bob.forceOutgoingExpired("m1") + bus.broadcast("bob", @[byte(3)], "m3") + + # Carol, on receiving Bob's repair request, must have dropped her own + # pending outgoingRepairBuffer entry for M1 (cancellation). + check "m1" notin carol.channels[testChannel].outgoingRepairBuffer + + test "response group filtering: only group members respond": + # With numGroups=10, roughly 1/10 of receivers will be in the group. + # Construct a sender+message where a specific non-sender is NOT in the group. + var cfg = defaultConfig() + cfg.numResponseGroups = 10 + + # Pick a msgId where carol is not in the group and bob is + # We probe deterministically because computeTReq/isInResponseGroup are pure. + var chosenMsg = "" + for i in 0 ..< 1000: + let candidate = "probe-" & $i + let bobIn = isInResponseGroup("bob", "alice", candidate, 10) + let carolIn = isInResponseGroup("carol", "alice", candidate, 10) + if bobIn and not carolIn: + chosenMsg = candidate + break + check chosenMsg.len > 0 + + let bus = newTestBus() + discard bus.addPeer("alice", cfg) + let bob = bus.addPeer("bob", cfg) + let carol = bus.addPeer("carol", cfg) + + # Both Bob and Carol receive the original M1 (so both have it in messageCache). + bus.broadcast("alice", @[byte(1)], chosenMsg) + + # Now Dave arrives: build a fake requester message manually so its repair_request + # names Alice as senderId for chosenMsg. + # We inject directly by calling unwrapReceivedMessage on bob/carol. + let dave = bus.addPeer("dave", cfg) + # Dave has no messages, but we can hand-craft a repair request he would send. + let reqMsg = SdsMessage( + messageId: "req-from-dave", + lamportTimestamp: 10, + causalHistory: @[], + channelId: testChannel, + content: @[byte(9)], + bloomFilter: @[], + senderId: "dave", + repairRequest: @[HistoryEntry(messageId: chosenMsg, senderId: "alice")], + ) + let bytes = serializeMessage(reqMsg).get() + discard bob.unwrapReceivedMessage(bytes) + discard carol.unwrapReceivedMessage(bytes) + + check: + chosenMsg in bob.channels[testChannel].incomingRepairBuffer + chosenMsg notin carol.channels[testChannel].incomingRepairBuffer + + test "multi-gap batch repair: many missing deps split across requests": + let bus = newTestBus() + discard bus.addPeer("alice") + let bob = bus.addPeer("bob") + + # Alice sends 5 messages while Bob is offline. + let drops = @["bob".SdsParticipantID] + bus.broadcast("alice", @[byte(1)], "m1", dropAt = drops) + bus.broadcast("alice", @[byte(2)], "m2", dropAt = drops) + bus.broadcast("alice", @[byte(3)], "m3", dropAt = drops) + bus.broadcast("alice", @[byte(4)], "m4", dropAt = drops) + bus.broadcast("alice", @[byte(5)], "m5", dropAt = drops) + + # Bob comes online and receives M6 which depends on m1..m5. + bus.broadcast("alice", @[byte(6)], "m6") + + # Bob should have 5 outgoing repair entries. + let channel = bob.channels[testChannel] + check channel.outgoingRepairBuffer.len == 5 + + # Force all to expired and wrap one message — only maxRepairRequests + # (default 3) should attach to a single outgoing message. + for id in ["m1", "m2", "m3", "m4", "m5"]: + bob.forceOutgoingExpired(id) + + let wrapped = bob.wrapOutgoingMessage(@[byte(99)], "bob-msg-1", testChannel).get() + let decoded = deserializeMessage(wrapped).get() + check decoded.repairRequest.len <= bob.config.maxRepairRequests + + # The attached entries should be removed from the outgoing buffer. + check channel.outgoingRepairBuffer.len == 5 - decoded.repairRequest.len + + test "markDependenciesMet externally clears pending repair entry": + let bus = newTestBus() + discard bus.addPeer("alice") + let bob = bus.addPeer("bob") + + bus.broadcast("alice", @[byte(1)], "m1", dropAt = @["bob".SdsParticipantID]) + bus.broadcast("alice", @[byte(2)], "m2") + check "m1" in bob.channels[testChannel].outgoingRepairBuffer + + # Simulate Bob fetching M1 via an out-of-band store query. + check bob.markDependenciesMet(@["m1"], testChannel).isOk() + check "m1" notin bob.channels[testChannel].outgoingRepairBuffer