logos-delivery/tests/channels/test_reliable_channel_send_receive.nim
Tanya S 54360d47bc
Move rate-limit-manager to messaging client layer (#4021)
* chore: drop rate-limit stage from reliable channel send pipeline

Collapses the outgoing pipeline to `segmentation -> sds -> encryption ->
dispatch` by folding the encrypt-and-dispatch tail of `onReadyToSend`
directly into `send()`. Removes the `RateLimitManager` field, its
constructor param, the `ReadyToSendEvent` listener, and the
`awaitingDispatch` accounting that only existed to bridge the event-bus
hop between `send()` and `onReadyToSend`.

The `rate_limit_manager.nim` module itself is untouched — it will be
relocated to the messaging layer (co-located with RLN) in a follow-up
commit, where per-epoch admission actually belongs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore: relocate RateLimitManager to messaging layer

Moves rate_limit_manager.nim from `logos_delivery/channels/` to
`logos_delivery/messaging/rate_limit_manager/` — the correct owner
now that admission sits alongside RLN in the messaging layer instead
of being fanned out to per-channel event listeners.

The API surface changes:

- `enqueueToSend` + `ReadyToSendEvent` (broker-based fan-out to a
  ReliableChannel listener) is replaced by `admit(msg): Future[Result[
  void, RateLimitError]]`. Callers now branch directly on the result
  instead of subscribing to an event.
- `channelId`, `SdsChannelID` and the SDS import are dropped — the
  messaging layer has no notion of channels; SDS was a channels-layer
  concern that only survived on this type because of the old broker
  fan-out.
- `brokerCtx` is dropped for the same reason.

Epoch config (`epochPeriodSec`), the wall-clock `currentEpochStart`,
`queue`, `dequeueReady`, and `resetEpoch` are preserved exactly as the
original owner designed them — this refactor is intentionally scoped
to the API surface, not the epoch mechanism.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat: meter SendService transmissions through RateLimitManager

Wires the relocated RateLimitManager into the messaging layer at the
transmission stage rather than the API entry point:

- MessagingClientConf gains a rateLimit: RateLimitConfig field
  (defaulting to DefaultEpochPeriodSec / DefaultMessagesPerEpoch), and
  MessagingClient.new hands the constructed manager to SendService.
- SendService consults admit() before the first transmission of a
  task, both in send() and in the retry loop. Re-publishes of an
  already-propagated message (firstPropagatedTime set) skip admission:
  they resend the same bytes, which reuse the same RLN proof/nullifier
  and consume no fresh epoch slot.
- An over-budget task parks in the task cache as NextRoundRetry; the
  service loop re-admits it as the epoch budget frees up. The skeleton
  admit() is a pass-through, so behaviour is unchanged today.

Gating transmissions instead of MessagingClient.send keeps SDS repair
rebroadcasts free of API-entry rejection (SDS decides that a repair is
needed; the transmission scheduler decides when it fits the budget)
while every wire transmission still draws from one node-wide budget --
which network-side RLN enforcement applies to repairs regardless of
any local bypass. It is also where RLN proof attachment must happen,
since proofs bind to the epoch current at transmission time.

Adds tests/messaging/test_rate_limit_manager.nim covering the current
disabled + enabled pass-through behaviour, wired into
all_tests_waku.nim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix: make MessagingClientConf.rateLimit reachable, warn on dead channel knobs

Addresses two review findings on the move PR:

- `rateLimit` was a plain `RateLimitConfig`, but `merge` only copies
  `Opt` fields (`when oField is Opt`), so every override path
  (`LogosDeliveryConf.init`, JSON `messagingOverrides`) silently dropped
  it and the field was always taken from `base` — unsettable by any
  caller. Make it `Opt[RateLimitConfig]` like every other field;
  `MessagingClient.new` falls back to `DefaultRateLimitConfig` (new
  const, rate limiting disabled) when unset. Adds a merge test.

- The channel-level knobs (`rateLimitEnabled` /
  `rateLimitEpochPeriodSec` / `rateLimitMessagesPerEpoch`) are still
  parsed but unread since rate limiting moved to the messaging client,
  so setting them was silently ignored. `ReliableChannelManager.new`
  now logs a deprecation warning when any is set. Full removal remains a
  follow-up API decision.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-22 14:38:33 +02:00

1173 lines
42 KiB
Nim

{.used.}
import results, std/[net, os]
from std/times import epochTime
import chronos, testutils/unittests, stew/byteutils
import brokers/broker_context
import ../testlib/[common, wakucore, wakunode, testasync]
import logos_delivery
import logos_delivery/waku/[waku_node, waku_core]
import logos_delivery/waku/factory/waku_conf
import logos_delivery/api/events/messaging_client_events as waku_message_events
import logos_delivery/api/messaging_client_api
import tools/confutils/cli_args
import logos_delivery/api/conf/messaging_conf
import logos_delivery/channels/reliable_channel_manager
import logos_delivery/channels/encryption/noop_encryption
import logos_delivery/waku/persistency/keys
import logos_delivery/waku/persistency/sds_persistency
## Full nim-sds API: ingress tests act as the remote peer producing real
## SDS envelopes; protocol-semantics tests decode wires and meta snapshots.
import sds
import snapshot_codec
const TestTimeout = chronos.seconds(15)
proc createApiNodeConf(): WakuNodeConf =
var conf = MessagingClientConf()
.toWakuNodeConf(messaging_conf.LogosDeliveryMode.Core).valueOr:
raiseAssert error
conf.listenAddress = parseIpAddress("0.0.0.0")
conf.tcpPort = Port(0)
conf.discv5UdpPort = Port(0)
conf.clusterId = Opt.some(3'u16)
conf.numShardsInNetwork = 1
conf.rest = false
return conf
suite "Reliable Channel - ingress":
asyncTest "manager dispatches marked WakuMessage to the right channel":
## Unit test for the receive side of the API: instead of standing
## up two libp2p nodes and a relay mesh, we drive the manager
## directly by emitting a `MessageReceivedEvent` (the exact event
## the MessagingClient emits when a `WakuMessage` arrives off the
## wire). The manager must:
## - drop traffic missing the Reliable Channel spec marker
## - dispatch the matching channel's `onMessageReceived`
## - emit `ChannelMessageReceivedEvent` with the payload
const
channelId = ChannelId("test-channel")
contentTopic = ContentTopic("/reliable-channel/test/proto")
let appPayload = "hello reliable channel".toBytes()
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
## Noop encryption providers so the Encrypt/Decrypt brokers have
## something to dispatch to; without this the channel falls back to
## plaintext anyway, but installing them is the documented setup.
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
let received = newFuture[seq[byte]]("channel-message-received")
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if not received.finished() and evt.channelId == channelId:
received.complete(evt.payload)
,
)
.expect("listen ChannelMessageReceivedEvent")
## Build a `WakuMessage` as it would arrive off the wire: spec marker
## on `meta`, right content topic, payload wrapped in a real SDS
## envelope by a stand-in remote peer.
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let sdsWire = (
await remotePeer.wrapOutgoingMessage(
appPayload, "ingress-test-msg-1", SdsChannelID(channelId)
)
).expect("wrapOutgoingMessage")
let inboundMsg = WakuMessage(
payload: sdsWire,
contentTopic: contentTopic,
version: 0,
meta: LipWireReliableChannelVersion.toBytes(),
)
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(messageHash: "", message: inboundMsg),
)
let arrived = await received.withTimeout(TestTimeout)
check arrived
if arrived:
check received.read() == appPayload
(await waku.stop()).expect("stop")
asyncTest "manager drops unmarked WakuMessage":
## Mirror of the above: same content topic, but `meta` is empty
## (i.e. foreign traffic). The channel-level event must NOT fire.
const
channelId = ChannelId("test-channel-2")
contentTopic = ContentTopic("/reliable-channel/test/proto")
let appPayload = "foreign payload".toBytes()
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var fired = false
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
fired = true
,
)
.expect("listen ChannelMessageReceivedEvent")
let inboundMsg = WakuMessage(
payload: appPayload,
contentTopic: contentTopic,
version: 0,
meta: @[], ## no Reliable Channel spec marker
)
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(messageHash: "", message: inboundMsg),
)
## Give the event broker a chance to fan out.
await sleepAsync(100.milliseconds)
check not fired
(await waku.stop()).expect("stop")
suite "Reliable Channel - send state machine":
asyncTest "MessageSentEvent finalises the channelReqId as Sent":
## Drives the real send pipeline (`send` -> segmentation -> SDS ->
## rate_limit -> encrypt -> dispatch) via a fake `MessagingSend` provider that
## returns a canned `RequestId` instead of hitting the network.
## Emitting the delivery-layer `MessageSentEvent` must drive the
## channel-level state machine through `Confirmed` and produce a
## `ChannelMessageSentEvent` (channel-level terminal event) for the
## `channelReqId` returned by `send()`.
const
channelId = ChannelId("sm-success-channel")
contentTopic = ContentTopic("/reliable-channel/test/sm-success")
fakeMsgReqId = RequestId("fake-msg-req-1")
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
var sendCalls = 0
MessagingSend.replaceProvider(
brokerCtx,
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
sendCalls.inc
return ok(fakeMsgReqId),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
let sentFut = newFuture[RequestId]("channel-sent")
discard ChannelMessageSentEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageSentEvent) {.async: (raises: []).} =
if not sentFut.finished() and evt.channelId == channelId:
sentFut.complete(evt.requestId)
,
)
.expect("listen ChannelMessageSentEvent")
let channelReqId = (await manager.send(channelId, "hello".toBytes())).expect("send")
let dispatchDeadline = Moment.now() + 1.seconds
while Moment.now() < dispatchDeadline and sendCalls == 0:
await sleepAsync(5.milliseconds)
check sendCalls == 1
waku_message_events.MessageSentEvent.emit(
brokerCtx,
waku_message_events.MessageSentEvent(requestId: fakeMsgReqId, messageHash: ""),
)
let finalised = await sentFut.withTimeout(1.seconds)
check finalised
if finalised:
check sentFut.read() == channelReqId
(await waku.stop()).expect("stop")
asyncTest "two independent channelReqIds are finalised independently":
## Two `send()` calls -> two independent `channelReqId`s, each with
## one segment under the current segmentation skeleton
## (`performSegmentation` always emits exactly one segment). The
## fake `MessagingSend` provider returns distinct `messagingReqId`s; finalising
## the first emits `ChannelMessageSentEvent` for its `channelReqId`,
## finalising the second as a failure emits `ChannelMessageErrorEvent`
## for the other.
const
channelId = ChannelId("sm-multi-channel")
contentTopic = ContentTopic("/reliable-channel/test/sm-multi")
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
var msgReqIds: seq[RequestId]
MessagingSend.replaceProvider(
brokerCtx,
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
let id = RequestId("fake-msg-req-" & $(msgReqIds.len + 1))
msgReqIds.add(id)
return ok(id),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
let sentFut = newFuture[RequestId]("channel-sent")
let erroredFut = newFuture[RequestId]("channel-errored")
discard ChannelMessageSentEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageSentEvent) {.async: (raises: []).} =
if not sentFut.finished() and evt.channelId == channelId:
sentFut.complete(evt.requestId)
,
)
.expect("listen ChannelMessageSentEvent")
discard ChannelMessageErrorEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageErrorEvent) {.async: (raises: []).} =
if not erroredFut.finished() and evt.channelId == channelId:
erroredFut.complete(evt.requestId)
,
)
.expect("listen ChannelMessageErrorEvent")
let channelReqId1 =
(await manager.send(channelId, "first".toBytes())).expect("send 1")
let channelReqId2 =
(await manager.send(channelId, "second".toBytes())).expect("send 2")
let dispatchDeadline = Moment.now() + 1.seconds
while Moment.now() < dispatchDeadline and msgReqIds.len < 2:
await sleepAsync(5.milliseconds)
check msgReqIds.len == 2
waku_message_events.MessageSentEvent.emit(
brokerCtx,
waku_message_events.MessageSentEvent(requestId: msgReqIds[0], messageHash: ""),
)
let sentArrived = await sentFut.withTimeout(1.seconds)
check sentArrived
if sentArrived:
check sentFut.read() == channelReqId1
## The second `channelReqId` must NOT have finalised yet — its
## segment is still `InFlight`.
check not erroredFut.finished()
waku_message_events.MessageErrorEvent.emit(
brokerCtx,
waku_message_events.MessageErrorEvent(
requestId: msgReqIds[1], messageHash: "", error: "synthetic"
),
)
let erroredArrived = await erroredFut.withTimeout(1.seconds)
check erroredArrived
if erroredArrived:
check erroredFut.read() == channelReqId2
(await waku.stop()).expect("stop")
asyncTest "TODO: channelReqId not pruned until ALL its segments are final":
## Placeholder for the multi-sibling prune rule. Today's
## `performSegmentation` (segmentation skeleton) always emits
## exactly one segment per `send()`, so multiple siblings under one
## `channelReqId` cannot be produced through the real pipeline.
## Implement once segmentation does real chunking: send a payload
## larger than `DefaultSegmentSizeBytes`, capture the N
## `messagingReqId`s from a fake `MessagingSend` provider, finalise some, and
## assert prune only fires once every sibling is final.
skip()
asyncTest "sibling MessageSentEvent during sendHandler await does not corrupt state":
## Regression test for the prune-during-await race
## (PR #3914 review comment r3324891059). Locks in that a sibling
## `MessageSentEvent` firing while `send` is paused at a
## `MessagingSend.request` await does not lose the second
## `channelReqId`'s terminal event.
const
channelId = ChannelId("sm-race-channel")
contentTopic = ContentTopic("/reliable-channel/test/sm-race")
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
var msgReqIds: seq[RequestId]
var sendsReturned = 0
MessagingSend.replaceProvider(
brokerCtx,
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
## Call 2 fires the first segment's terminal event and then
## yields, so the listener task runs while the second segment
## is still mid-`await` inside `send` — the exact race window
## the regression test targets.
let id = RequestId("race-msg-req-" & $(msgReqIds.len + 1))
msgReqIds.add(id)
if msgReqIds.len == 2:
MessageSentEvent.emit(
brokerCtx, MessageSentEvent(requestId: msgReqIds[0], messageHash: "")
)
await sleepAsync(50.milliseconds)
sendsReturned.inc()
return ok(id),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var finalisedReqIds: seq[RequestId]
let bothFinalised = newFuture[void]("both-finalised")
discard ChannelMessageSentEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageSentEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
finalisedReqIds.add(evt.requestId)
if finalisedReqIds.len == 2 and not bothFinalised.finished():
bothFinalised.complete()
,
)
.expect("listen ChannelMessageSentEvent")
let channelReqId1 =
(await manager.send(channelId, "first".toBytes())).expect("send 1")
## Drain the first segment fully before queueing the second, so
## inter-send ordering isn't itself under test here.
let firstDispatched = Moment.now() + 1.seconds
while Moment.now() < firstDispatched and msgReqIds.len < 1:
await sleepAsync(5.milliseconds)
check msgReqIds.len == 1
let channelReqId2 =
(await manager.send(channelId, "second".toBytes())).expect("send 2")
## Wait until `fakeSend(m2)` has fully returned and yield once
## more so `send`'s post-await continuation gets a chance to
## register `id2` in `inflightMessagingIds` before we emit its
## terminal event.
let dispatchDeadline = Moment.now() + 1.seconds
while Moment.now() < dispatchDeadline and sendsReturned < 2:
await sleepAsync(5.milliseconds)
check sendsReturned == 2
await sleepAsync(50.milliseconds)
## Finalise the second segment from the outside. If the race
## corrupted state, `channelReqId2`'s entry would never reach
## `inflightMessagingIds` and this event would silently miss.
waku_message_events.MessageSentEvent.emit(
brokerCtx,
waku_message_events.MessageSentEvent(requestId: msgReqIds[1], messageHash: ""),
)
let arrived = await bothFinalised.withTimeout(2.seconds)
check arrived
if arrived:
check finalisedReqIds.len == 2
check channelReqId1 in finalisedReqIds
check channelReqId2 in finalisedReqIds
(await waku.stop()).expect("stop")
suite "Reliable Channel - SDS persistence":
asyncTest "send persists SDS channel state through the persistency job":
## A send must flush `sds.meta` + `sds.log` through the shared "sds"
## job. Writes resolve on enqueue, so reads poll.
const
channelId = ChannelId("sds-persist-channel")
contentTopic = ContentTopic("/reliable-channel/test/persist")
Persistency.reset()
let root = getTempDir() / ("reliable_channel_sds_" & $epochTime().int)
removeDir(root)
let persistency = Persistency.instance(root).expect("persistency init")
defer:
Persistency.reset()
removeDir(root)
var waku: LogosDelivery
var manager: ReliableChannelManager
lockNewGlobalBrokerContext:
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
MessagingSend.replaceProvider(
globalBrokerContext(),
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
return ok(RequestId("persist-msg-req-1")),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
discard (await manager.send(channelId, "persist me".toBytes())).expect("send")
## Same handle the channel layer writes through (`openJob` is idempotent).
let job = persistency.openJob("sds").expect("openJob sds")
let chanKey = toKey(SdsChannelID(channelId))
proc pollMetaExists(): Future[bool] {.async.} =
let deadline = Moment.now() + 2.seconds
while Moment.now() < deadline:
let r = await job.exists(CatMeta, chanKey)
if r.isOk() and r.get():
return true
await sleepAsync(5.milliseconds)
return false
proc pollLogRow(): Future[bool] {.async.} =
## `sds.log` keys rows by (channelId, messageId) — scan the prefix.
let deadline = Moment.now() + 2.seconds
while Moment.now() < deadline:
let r = await job.scanPrefix(CatLog, chanKey)
if r.isOk() and r.get().len > 0:
return true
await sleepAsync(5.milliseconds)
return false
check await pollMetaExists()
check await pollLogRow()
(await waku.stop()).expect("stop")
## A marked WakuMessage carrying an SDS envelope, as it arrives off the wire.
proc sdsWakuMessage(contentTopic: ContentTopic, sdsWire: seq[byte]): WakuMessage =
WakuMessage(
payload: sdsWire,
contentTopic: contentTopic,
version: 0,
meta: LipWireReliableChannelVersion.toBytes(),
)
suite "Reliable Channel - SDS lifecycle":
asyncTest "out-of-order segments are parked and delivered in causal order":
## m2 depends on m1: m2 alone delivers nothing; m1 then delivers both,
## in causal order.
const
channelId = ChannelId("sds-causal-channel")
contentTopic = ContentTopic("/reliable-channel/test/causal")
let payload1 = "first message".toBytes()
let payload2 = "second message".toBytes()
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var deliveries: seq[seq[byte]]
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
deliveries.add(evt.payload)
,
)
.expect("listen ChannelMessageReceivedEvent")
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let wire1 = (
await remotePeer.wrapOutgoingMessage(
payload1, "causal-m1", SdsChannelID(channelId)
)
).expect("wrap m1")
let wire2 = (
await remotePeer.wrapOutgoingMessage(
payload2, "causal-m2", SdsChannelID(channelId)
)
).expect("wrap m2")
## m2 first: missing dependency m1 -> parked, nothing delivered.
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "hash-m2", message: sdsWakuMessage(contentTopic, wire2)
),
)
await sleepAsync(100.milliseconds)
check deliveries.len == 0
## m1 arrives: m1 delivered, then the parked m2 released after it.
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "hash-m1", message: sdsWakuMessage(contentTopic, wire1)
),
)
let deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and deliveries.len < 2:
await sleepAsync(5.milliseconds)
check deliveries.len == 2
if deliveries.len == 2:
check deliveries[0] == payload1
check deliveries[1] == payload2
(await waku.stop()).expect("stop")
asyncTest "duplicate SDS envelope is delivered to the app only once":
const
channelId = ChannelId("sds-dup-channel")
contentTopic = ContentTopic("/reliable-channel/test/dup")
let appPayload = "deliver once".toBytes()
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var deliveryCount = 0
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
deliveryCount.inc()
,
)
.expect("listen ChannelMessageReceivedEvent")
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let wire = (
await remotePeer.wrapOutgoingMessage(
appPayload, "dup-m1", SdsChannelID(channelId)
)
).expect("wrap")
## Same envelope twice (different hashes) — the second must be suppressed.
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "dup-hash-1", message: sdsWakuMessage(contentTopic, wire)
),
)
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "dup-hash-2", message: sdsWakuMessage(contentTopic, wire)
),
)
await sleepAsync(200.milliseconds)
check deliveryCount == 1
(await waku.stop()).expect("stop")
asyncTest "SDS envelope for a foreign channel is dropped":
## Same content topic, different SDS channel id — dropped before unwrap.
const
channelId = ChannelId("sds-foreign-channel")
contentTopic = ContentTopic("/reliable-channel/test/foreign")
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var fired = false
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
fired = true
,
)
.expect("listen ChannelMessageReceivedEvent")
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let wire = (
await remotePeer.wrapOutgoingMessage(
"not for you".toBytes(), "foreign-m1", SdsChannelID("some-other-channel")
)
).expect("wrap")
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "foreign-hash", message: sdsWakuMessage(contentTopic, wire)
),
)
await sleepAsync(200.milliseconds)
check not fired
(await waku.stop()).expect("stop")
asyncTest "received history survives channel close and re-create":
## Receive m1, close, re-create, replay m1: the duplicate is only
## suppressed if the history was actually restored from SQLite.
const
channelId = ChannelId("sds-restore-channel")
contentTopic = ContentTopic("/reliable-channel/test/restore")
let appPayload = "survive restart".toBytes()
Persistency.reset()
let root = getTempDir() / ("reliable_channel_sds_restore_" & $epochTime().int)
removeDir(root)
let persistency = Persistency.instance(root).expect("persistency init")
defer:
Persistency.reset()
removeDir(root)
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var deliveryCount = 0
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
deliveryCount.inc()
,
)
.expect("listen ChannelMessageReceivedEvent")
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let wire = (
await remotePeer.wrapOutgoingMessage(
appPayload, "restore-m1", SdsChannelID(channelId)
)
).expect("wrap")
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "restore-hash-1", message: sdsWakuMessage(contentTopic, wire)
),
)
var deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and deliveryCount < 1:
await sleepAsync(5.milliseconds)
check deliveryCount == 1
## Writes resolve on enqueue — wait until the row is applied before closing.
let job = persistency.openJob("sds").expect("openJob sds")
let chanKey = toKey(SdsChannelID(channelId))
deadline = Moment.now() + 2.seconds
var logVisible = false
while Moment.now() < deadline and not logVisible:
let r = await job.scanPrefix(CatLog, chanKey)
logVisible = r.isOk() and r.get().len > 0
if not logVisible:
await sleepAsync(5.milliseconds)
check logVisible
(await manager.closeChannel(channelId)).expect("closeChannel")
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("re-createReliableChannel")
## Replay the same envelope. Only a restored history suppresses it.
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "restore-hash-2", message: sdsWakuMessage(contentTopic, wire)
),
)
await sleepAsync(300.milliseconds)
check deliveryCount == 1
(await waku.stop()).expect("stop")
suite "Reliable Channel - SDS protocol semantics":
asyncTest "a reply references the received message and advances the lamport clock":
## After receiving m1, our outgoing wire must reference m1 in its causal
## history (that reference IS the ack) with a higher lamport.
const
channelId = ChannelId("sds-semantics-channel")
contentTopic = ContentTopic("/reliable-channel/test/semantics")
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
var capturedWires: seq[seq[byte]]
MessagingSend.replaceProvider(
brokerCtx,
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
## Noop encryption is identity, so the envelope payload IS the SDS wire.
capturedWires.add(envelope.payload)
return ok(RequestId("semantics-req-" & $capturedWires.len)),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let wire1 = (
await remotePeer.wrapOutgoingMessage(
"from remote".toBytes(), "semantics-m1", SdsChannelID(channelId)
)
).expect("wrap m1")
let m1 = deserializeMessage(wire1).expect("deserialize m1")
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "semantics-hash-1", message: sdsWakuMessage(contentTopic, wire1)
),
)
await sleepAsync(100.milliseconds)
discard (await manager.send(channelId, "reply".toBytes())).expect("send")
var deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and capturedWires.len < 1:
await sleepAsync(5.milliseconds)
check capturedWires.len == 1
let reply = deserializeMessage(capturedWires[0]).expect("deserialize reply")
check SdsMessageID("semantics-m1") in reply.causalHistory.getMessageIds()
check reply.lamportTimestamp > m1.lamportTimestamp
(await waku.stop()).expect("stop")
asyncTest "an unacknowledged send is acked by a later remote message":
## Our send sits in the outgoing buffer (visible in the persisted meta)
## until any later remote message references it — then the buffer drains.
const
channelId = ChannelId("sds-ack-channel")
contentTopic = ContentTopic("/reliable-channel/test/ack")
Persistency.reset()
let root = getTempDir() / ("reliable_channel_sds_ack_" & $epochTime().int)
removeDir(root)
let persistency = Persistency.instance(root).expect("persistency init")
defer:
Persistency.reset()
removeDir(root)
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
var capturedWires: seq[seq[byte]]
MessagingSend.replaceProvider(
brokerCtx,
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
## Noop encryption is identity, so the envelope payload IS the SDS wire.
capturedWires.add(envelope.payload)
return ok(RequestId("ack-req-" & $capturedWires.len)),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
discard (await manager.send(channelId, "needs ack".toBytes())).expect("send")
var deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and capturedWires.len < 1:
await sleepAsync(5.milliseconds)
check capturedWires.len == 1
let job = persistency.openJob("sds").expect("openJob sds")
let chanKey = toKey(SdsChannelID(channelId))
proc outgoingBufferLen(): Future[int] {.async.} =
## Decode the persisted meta snapshot; -1 while not yet readable.
let r = await job.get(CatMeta, chanKey)
if r.isErr() or r.get().isNone():
return -1
let meta = ChannelMeta.decode(r.get().get()).valueOr:
return -1
return meta.outgoingBuffer.len
## After the send the message must sit unacknowledged in the buffer.
deadline = Moment.now() + 2.seconds
var bufLen = -1
while Moment.now() < deadline and bufLen != 1:
bufLen = await outgoingBufferLen()
if bufLen != 1:
await sleepAsync(5.milliseconds)
check bufLen == 1
## The remote received our wire; its next message references it.
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
discard
(await remotePeer.unwrapReceivedMessage(capturedWires[0])).expect("remote unwrap")
let ackCarrier = (
await remotePeer.wrapOutgoingMessage(
"any later message".toBytes(), "ack-carrier-1", SdsChannelID(channelId)
)
).expect("wrap ack carrier")
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "ack-hash-1", message: sdsWakuMessage(contentTopic, ackCarrier)
),
)
## Receiving it must drain the outgoing buffer (op-end meta flush).
deadline = Moment.now() + 2.seconds
bufLen = -1
while Moment.now() < deadline and bufLen != 0:
bufLen = await outgoingBufferLen()
if bufLen != 0:
await sleepAsync(5.milliseconds)
check bufLen == 0
(await waku.stop()).expect("stop")
asyncTest "three-deep dependency chain is released in causal order":
## m1 <- m2 <- m3 arriving as m3, m2, m1: all held until m1 lands,
## then released as m1, m2, m3.
const
channelId = ChannelId("sds-chain-channel")
contentTopic = ContentTopic("/reliable-channel/test/chain")
let payloads =
@["chain first".toBytes(), "chain second".toBytes(), "chain third".toBytes()]
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var deliveries: seq[seq[byte]]
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
deliveries.add(evt.payload)
,
)
.expect("listen ChannelMessageReceivedEvent")
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
var wires: seq[seq[byte]]
for i in 0 .. 2:
wires.add(
(
await remotePeer.wrapOutgoingMessage(
payloads[i], "chain-m" & $(i + 1), SdsChannelID(channelId)
)
).expect("wrap chain-m" & $(i + 1))
)
## Deepest first: m3, then m2 — both must be parked.
for i in [2, 1]:
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "chain-hash-" & $(i + 1),
message: sdsWakuMessage(contentTopic, wires[i]),
),
)
await sleepAsync(150.milliseconds)
check deliveries.len == 0
## The root arrives: everything drains in causal order.
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "chain-hash-1", message: sdsWakuMessage(contentTopic, wires[0])
),
)
let deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and deliveries.len < 3:
await sleepAsync(5.milliseconds)
check deliveries.len == 3
if deliveries.len == 3:
check deliveries[0] == payloads[0]
check deliveries[1] == payloads[1]
check deliveries[2] == payloads[2]
(await waku.stop()).expect("stop")
asyncTest "sync envelope without app payload is consumed silently":
## Sync traffic has no app payload: no event, and normal traffic
## keeps flowing afterwards.
const
channelId = ChannelId("sds-sync-channel")
contentTopic = ContentTopic("/reliable-channel/test/sync")
let appPayload = "real message".toBytes()
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var deliveryCount = 0
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
deliveryCount.inc()
,
)
.expect("listen ChannelMessageReceivedEvent")
## Hand-built sync envelope: valid SDS message, empty content.
let syncMsg = SdsMessage.init(
messageId = SdsMessageID("sync-1"),
lamportTimestamp = 42,
causalHistory = @[],
channelId = SdsChannelID(channelId),
content = @[],
bloomFilter = @[],
)
let syncWire = serializeMessage(syncMsg).expect("serialize sync")
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "sync-hash-1", message: sdsWakuMessage(contentTopic, syncWire)
),
)
await sleepAsync(150.milliseconds)
check deliveryCount == 0
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
let wire = (
await remotePeer.wrapOutgoingMessage(
appPayload, "sync-m1", SdsChannelID(channelId)
)
).expect("wrap")
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "sync-hash-2", message: sdsWakuMessage(contentTopic, wire)
),
)
let deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and deliveryCount < 1:
await sleepAsync(5.milliseconds)
check deliveryCount == 1
(await waku.stop()).expect("stop")
asyncTest "identical payloads get distinct message ids and both deliver":
## Identical content sent twice must get distinct message ids and
## reach the app twice — not collapse via the SDS duplicate check.
const
channelId = ChannelId("sds-unique-id-channel")
contentTopic = ContentTopic("/reliable-channel/test/unique-id")
let appPayload = "ok".toBytes()
var waku: LogosDelivery
var manager: ReliableChannelManager
var brokerCtx: BrokerContext
lockNewGlobalBrokerContext:
brokerCtx = globalBrokerContext()
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
setNoopEncryption()
var capturedWires: seq[seq[byte]]
MessagingSend.replaceProvider(
brokerCtx,
proc(envelope: MessageEnvelope): Future[Result[RequestId, string]] {.async.} =
## Noop encryption is identity, so the envelope payload IS the SDS wire.
capturedWires.add(envelope.payload)
return ok(RequestId("unique-req-" & $capturedWires.len)),
).isOkOr:
raiseAssert "replaceProvider failed: " & error
discard manager
.createReliableChannel(channelId, contentTopic, SdsParticipantID("local"))
.expect("createReliableChannel")
var deliveries: seq[seq[byte]]
discard ChannelMessageReceivedEvent
.listen(
brokerCtx,
proc(evt: ChannelMessageReceivedEvent) {.async: (raises: []).} =
if evt.channelId == channelId:
deliveries.add(evt.payload)
,
)
.expect("listen ChannelMessageReceivedEvent")
## Send side: the same payload twice must produce two distinct ids.
discard (await manager.send(channelId, appPayload)).expect("send 1")
discard (await manager.send(channelId, appPayload)).expect("send 2")
var deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and capturedWires.len < 2:
await sleepAsync(5.milliseconds)
check capturedWires.len == 2
let id1 = deserializeMessage(capturedWires[0]).expect("wire 1").messageId
let id2 = deserializeMessage(capturedWires[1]).expect("wire 2").messageId
check id1 != id2
## Receive side: identical content under distinct ids delivers twice.
let remotePeer =
ReliabilityManager.new(SdsParticipantID("remote"), ReliabilityConfig.init())
for i in 1 .. 2:
let wire = (
await remotePeer.wrapOutgoingMessage(
appPayload, "unique-m" & $i, SdsChannelID(channelId)
)
).expect("wrap " & $i)
waku_message_events.MessageReceivedEvent.emit(
brokerCtx,
waku_message_events.MessageReceivedEvent(
messageHash: "unique-hash-" & $i, message: sdsWakuMessage(contentTopic, wire)
),
)
deadline = Moment.now() + 2.seconds
while Moment.now() < deadline and deliveries.len < 2:
await sleepAsync(5.milliseconds)
check deliveries.len == 2
(await waku.stop()).expect("stop")
asyncTest "manager rejects operations on unknown channels":
var waku: LogosDelivery
var manager: ReliableChannelManager
lockNewGlobalBrokerContext:
waku = (await LogosDelivery.new(createApiNodeConf())).expect("LogosDelivery.new")
manager = waku.reliableChannelManager
check (await manager.send(ChannelId("no-such-channel"), "x".toBytes())).isErr()
check (await manager.closeChannel(ChannelId("no-such-channel"))).isErr()
(await waku.stop()).expect("stop")