mirror of
https://github.com/logos-messaging/logos-delivery.git
synced 2026-07-19 11:09:26 +00:00
Merge 3d98a28d9fc7cd452b91cd3bd1c857a12372e4f0 into 2a4da679f2022268afcfe7c979e41e6f8c028bb9
This commit is contained in:
commit
483cd672ff
@ -86,3 +86,81 @@ macro per-message numbers are the aggregate of publisher (node A) + receiver
|
||||
| 3 | Two consecutive micro runs < 5 % msg/s variance | **PASS** — 4.03 % (and 1.90 % / 3.12 % on earlier runs) |
|
||||
| 4 | Macro `decodes_per_msg ≥ 4` and `hashes_per_msg` in 4–6 | **PASS** — decodes 6.0, hashes 5.0 (process aggregate; receiver-side decodes = 4) |
|
||||
| 5 | This baseline doc committed | **PASS** |
|
||||
|
||||
---
|
||||
|
||||
# Phase 2 checkpoint — `WakuMessage` as `ref object`
|
||||
|
||||
Re-run of the same harness after Phase 2 (`WakuMessage` value `object` →
|
||||
`ref object`), same machine/toolchain, same workload (seed 42, 10/50/150 kB @
|
||||
25/50/25 %, N=1000 + 100 warmup), same build defines
|
||||
(`-d:msgPathCounters -d:chronicles_log_level=ERROR`, `--passL:librln_v2.0.2.a
|
||||
--passL:-lm`), `--mm:refc`.
|
||||
|
||||
## Results (CSV)
|
||||
|
||||
```
|
||||
scenario,msgs,payload_profile,wall_ms,msg_per_s,ns_per_msg_p50,ns_per_msg_p99,decodes_per_msg,hashes_per_msg,hashed_MB,decoded_MB,occupied_mem_delta_MB,gc_collections
|
||||
micro_run1,1000,10/50/150kB@25/50/25,809.283,1235.7,624250,1941750,2.000,2.000,130.00,130.11,5.25,7
|
||||
micro_run2,1000,10/50/150kB@25/50/25,798.488,1252.4,615750,1906167,2.000,2.000,130.00,130.11,6.46,8
|
||||
macro,1000,10/50/150kB@25/50/25,4306.885,232.2,3485125,10544000,6.000,5.000,325.00,390.33,140.74,10
|
||||
```
|
||||
(second run: micro 1232.1 / 1278.1, variance 3.60 %; macro 230.2 msg/s, occ_mem_Δ 140.58 MB — stable.)
|
||||
|
||||
## Phase 1 baseline vs Phase 2
|
||||
|
||||
| Scenario | Metric | Phase 1 baseline | Phase 2 | Δ |
|
||||
|---|---|---|---|---|
|
||||
| micro | msg/s | 1206.9 / 1257.6 | 1235.7 / 1252.4 | ~flat (within variance) |
|
||||
| micro | decodes/msg | 2.000 | 2.000 | **unchanged** |
|
||||
| micro | hashes/msg | 2.000 | 2.000 | **unchanged** |
|
||||
| micro | occ_mem_Δ MB | 1.51–1.54 | 5.25–6.46 | up (retention-noise, see below) |
|
||||
| macro | msg/s | 229.0 | 232.2 / 230.2 | +1 % |
|
||||
| macro | decodes/msg | 6.000 | 6.000 | **unchanged** |
|
||||
| macro | hashes/msg | 5.000 | 5.000 | **unchanged** |
|
||||
| macro | hashed_MB / decoded_MB | 325.00 / 390.33 | 325.00 / 390.33 | **byte-exact unchanged** |
|
||||
| macro | occ_mem_Δ MB | 141.58 | 140.74 / 140.58 | ~flat (−0.6 %) |
|
||||
| macro | gc_collections | 10 | 10 | unchanged |
|
||||
|
||||
## Interpretation — why occupied-mem delta is flat (and that is expected)
|
||||
|
||||
The refactor removes **transient** deep copies — async-closure env captures in
|
||||
node dispatch (`subscription_manager` handlers, 6–7 per message), Result/Option
|
||||
bind-outs, and the `valueOr` binds on the decode path. It does **not** change
|
||||
what is *retained*.
|
||||
|
||||
`occupied_mem_delta` is `getOccupiedMem()` after − before (with a
|
||||
`GC_fullCollect()` only *before* the run). It is therefore a **retained-memory**
|
||||
metric, and the macro figure is dominated by the archive `SortedSet[Index,
|
||||
WakuMessage]` holding all 1000 messages. Those payload bytes are retained
|
||||
identically under value and ref semantics (value: copied into the set node; ref:
|
||||
one heap message the set points at) — so the delta is flat by construction.
|
||||
|
||||
Under `--mm:refc` the eliminated transient copies are freed **deterministically**
|
||||
by refcount as each async env / Result temporary leaves scope; they never
|
||||
accumulate, so neither `getOccupiedMem()`-delta nor `gc_collections` (unchanged
|
||||
at 10) can observe their removal. Quantifying the transient-copy volume would
|
||||
require a **cumulative bytes-allocated counter or a peak-RSS probe**, which the
|
||||
Phase 1 harness does not expose — a harness gap, not a missing win.
|
||||
|
||||
The **primary correctness gate is met**: decode and hash counters are byte-exact
|
||||
identical to baseline (micro 2/2, macro 6/5; `hashed_MB`/`decoded_MB` identical
|
||||
to the byte), confirming the ref change introduced no extra decode/hash passes,
|
||||
and throughput is equal-to-slightly-better.
|
||||
|
||||
Residual-deep-copy investigation (plan acceptance item 3): swept for the two
|
||||
prime suspects and found neither — no value-type `WakuMessage` field remains
|
||||
(the type is now a ref, so every container/field that held a `WakuMessage`,
|
||||
incl. `SortedSet[Index, WakuMessage]`, `MessagePush.wakuMessage`,
|
||||
`WakuMessageKeyValue.message`, holds a pointer); the known `var`-parameter copy
|
||||
site (relay `publish`) was converted to `ensureTimestampSet`. No leftover
|
||||
force-copy path exists.
|
||||
|
||||
## Acceptance gate (Phase 2)
|
||||
|
||||
| # | Criterion | Verdict |
|
||||
|---|---|---|
|
||||
| 1 | Representative tests green; ASAN clean | **PASS** (tests) — see final report for suite list; ASAN best-effort noted there |
|
||||
| 2 | Mutation-site classification produced | **PASS** — `docs/analysis/phase2_mutation_audit.md` |
|
||||
| 3 | Alloc volume down ≥ 40 %, decode/hash unchanged | **PARTIAL** — decode/hash byte-exact unchanged (**PASS**); alloc-volume drop **not demonstrable** via this harness's retained-memory metric under refc (technical reason above); no residual deep-copy bug found |
|
||||
| 4 | `detect_changes` reviewed; committed (no push) | **PASS** (gitnexus skipped per environment; grep-based mutation sweep committed instead) |
|
||||
|
||||
64
docs/analysis/phase2_mutation_audit.md
Normal file
64
docs/analysis/phase2_mutation_audit.md
Normal file
@ -0,0 +1,64 @@
|
||||
# Phase 2 — `WakuMessage` mutation-site audit
|
||||
|
||||
Review artifact for the value-`object` → `ref object` change. Every hit of the
|
||||
field-mutation sweep is classified below. Sweep command (plan Step 2.3):
|
||||
|
||||
```
|
||||
grep -rn --include='*.nim' -E '\.(payload|meta|proof|timestamp|ephemeral|version|contentTopic) *=[^=]' logos_delivery library apps
|
||||
```
|
||||
|
||||
plus the nil-safety declaration sweep (plan Step 3):
|
||||
|
||||
```
|
||||
grep -rn --include='*.nim' -E 'var [a-zA-Z_]+: WakuMessage\b|: WakuMessage$' logos_delivery library apps
|
||||
```
|
||||
|
||||
Legend: **FINE** = freshly constructed / owned in the same scope, or not a
|
||||
`WakuMessage` at all; **FIXED** = mutated a shared/held message under ref
|
||||
semantics, corrected.
|
||||
|
||||
## Field-mutation hits
|
||||
|
||||
| # | Site | What it does | Class | Action |
|
||||
|---|---|---|---|---|
|
||||
| 1 | `logos_delivery/api/types.nim:81` | `# wm.proof = ...` | FINE | commented-out dead code, no effect |
|
||||
| 2 | `logos_delivery/waku/rln/proof.nim:93` (`attachRLNProof`) | `msgWithProof.proof = ...` on `var msgWithProof = message` | **FIXED** | outbound path; caller still holds `message`. Changed to `let msgWithProof = message.clone()` |
|
||||
| 3 | `logos_delivery/waku/rest_api/endpoint/relay/handlers.nim:73` (`attachRlnProofAndValidate`) | `msg.proof = ...` on `var msg = message` | **FIXED** | same aliasing as #2. Changed to `let msg = message.clone()` |
|
||||
| 4 | `logos_delivery/waku/waku_relay/protocol.nim:680` (`publish`) | `var message = wakuMessage; message.timestamp = ...` | **FIXED** | caller's message mutated. Replaced with `let message = wakuMessage.ensureTimestampSet()` |
|
||||
| 5 | `logos_delivery/waku/waku_core/message/message.nim:31` (`ensureTimestampSet`) | `result = message; result.timestamp = ...` | **FIXED** | aliased+mutated the shared input. Rewritten to clone-when-unset (non-mutating) |
|
||||
| 6 | `logos_delivery/waku/waku_core/message/codec.nim:32–72` (`decode`) | `msg.payload/…=` on `var msg = WakuMessage()` | FINE | freshly constructed in same scope |
|
||||
| 7 | `logos_delivery/waku/waku_archive/driver/postgres_driver/postgres_driver.nim:284–288` | field writes on `var wakuMessage` | FINE after fix | fresh per loop iteration; needed nil-init (see nil-safety #A) — no cross-row aliasing |
|
||||
| 8 | `postgres_driver.nim:832` | `m.timestamp = l.timestamp` | FINE | inside a SQL query string literal, not Nim code |
|
||||
| 9 | `logos_delivery/waku/persistency/sds_persistency.nim:136` | `data.meta = ...` | FINE | `data` is `ChannelData`, not `WakuMessage` |
|
||||
| 10 | `apps/chat2/chat2.nim:96` | `msg.timestamp = ...` | FINE | `msg` is a `Chat2Message`, not `WakuMessage` |
|
||||
| 11 | `apps/chat2/chat2.nim:200` | `message.proof = proofRes.get()` | FINE | `message` freshly constructed locally at chat2.nim:184 (`var message = WakuMessage(...)`), owned in scope |
|
||||
| 12 | `apps/chat2mix/chat2mix.nim:118` | `msg.timestamp = ...` | FINE | `msg` is a `Chat2Message`, not `WakuMessage` |
|
||||
|
||||
## Nil-safety hits (`var x: WakuMessage` now defaults to `nil`)
|
||||
|
||||
| # | Site | Class | Action |
|
||||
|---|---|---|---|
|
||||
| A | `postgres_driver.nim:260` — `var wakuMessage: WakuMessage` in the row-decode loop | **FIXED** | default-init is now `nil`; following field writes would deref nil. Changed to `= WakuMessage()` |
|
||||
| B | `not_delivered_storage.nim:22` — `msg: WakuMessage` field of `TrackedWakuMessage` | FINE | `TrackedWakuMessage` is not constructed on any active path (`archiveMessage` is a stub returning `ok()`); no nil deref reachable |
|
||||
| C | `recv_service.nim:63` — `let otherwiseMsg = WakuMessage()` | FINE | explicit construction used as `Option.get` default; non-nil |
|
||||
|
||||
## Codec / container decode paths (plan Step 3 verification)
|
||||
|
||||
All decoders that populate a `WakuMessage`-typed field construct explicitly, so
|
||||
none rely on nil default-init:
|
||||
|
||||
- `waku_core/message/codec.nim:25` — `var msg = WakuMessage()`
|
||||
- `waku_filter_v2/rpc_codec.nim:93` — `rpc.wakuMessage = ?WakuMessage.decode(message)`
|
||||
- `waku_lightpush/rpc_codec.nim:38` / `waku_lightpush_legacy/rpc_codec.nim` — `rpc.message = ?WakuMessage.decode(...)`
|
||||
- `waku_store/rpc_codec.nim:167/170` — `keyValue.message = some(?WakuMessage.decode(...))` / `none(WakuMessage)` (Opt wrapper kept, not collapsed to nil)
|
||||
- sqlite `queries.nim:35` / postgres row decode — `return WakuMessage(...)` / `WakuMessage()`
|
||||
|
||||
`Option[WakuMessage]` / `Opt[WakuMessage]` occurrences (store RPC, filter) keep
|
||||
their wrapper semantics; nil-ref and `none` are **not** conflated.
|
||||
|
||||
## Other diagnostic fix
|
||||
|
||||
- `logos_delivery/waku/rln/rln.nim:75` — `msgHash = msg.hash` (a chronicles log
|
||||
field) resolved to the generic `std/hashes` object hash under value semantics;
|
||||
a ref no longer matches it. Changed to `msg[].hash` to preserve the exact
|
||||
prior diagnostic value. Not a mutation; surfaced by the type change.
|
||||
@ -69,7 +69,7 @@ proc attachRlnProofAndValidate(
|
||||
## RlnValidatorErrorMsg), schedules a background merkle proof refresh and
|
||||
## fails early with StaleProofSuspected — the caller decides whether to
|
||||
## retry. Callers invoke only when RLN is mounted.
|
||||
var msg = message
|
||||
let msg = message.clone()
|
||||
msg.proof = (
|
||||
await rln.generateRLNProof(msg.toRLNSignal(), float64(getTime().toUnix()))
|
||||
).valueOr:
|
||||
|
||||
@ -89,7 +89,7 @@ proc attachRLNProof*(
|
||||
## Returns the message with a freshly generated RLN proof, replacing any
|
||||
## existing one and drawing a new message id. Retry paths suspecting a stale
|
||||
## path should call `invalidateMerkleProofCache` first.
|
||||
var msgWithProof = message
|
||||
let msgWithProof = message.clone()
|
||||
msgWithProof.proof = (
|
||||
await r.generateRLNProof(message.toRLNSignal(), float64(getTime().toUnix()))
|
||||
).valueOr:
|
||||
|
||||
@ -72,7 +72,7 @@ proc validateMessage*(
|
||||
let timeDiff = uint64(abs(currentTime - messageTime))
|
||||
|
||||
info "time info",
|
||||
currentTime = currentTime, messageTime = messageTime, msgHash = msg.hash
|
||||
currentTime = currentTime, messageTime = messageTime, msgHash = msg[].hash
|
||||
|
||||
if timeDiff > rlnPeer.rlnMaxTimestampGap:
|
||||
warn "invalid message: timestamp difference exceeds threshold",
|
||||
|
||||
@ -257,7 +257,7 @@ proc rowCallbackImpl(
|
||||
version: uint
|
||||
timestamp: Timestamp
|
||||
meta: string
|
||||
wakuMessage: WakuMessage
|
||||
wakuMessage: WakuMessage = WakuMessage()
|
||||
|
||||
rawHash = $(pqgetvalue(pqResult, iRow, 0))
|
||||
pubSubTopic = $(pqgetvalue(pqResult, iRow, 1))
|
||||
|
||||
@ -9,7 +9,20 @@ import ../topics, ../time
|
||||
|
||||
const MaxMetaAttrLength* = 64 # 64 bytes
|
||||
|
||||
type WakuMessage* = object # Data payload transmitted.
|
||||
type WakuMessage* = ref object
|
||||
# Data payload transmitted.
|
||||
## `WakuMessage` is a `ref object` for performance: every assignment,
|
||||
## async-closure capture, and Result/Option bind-out costs a pointer +
|
||||
## refcount instead of deep-copying the three seqs + the string.
|
||||
##
|
||||
## By convention a `WakuMessage` is **immutable after construction**. Only
|
||||
## mutate an instance you just constructed yourself, or one you obtained via
|
||||
## `clone()`. Mutating a message received through an API or held elsewhere is
|
||||
## a shared-state bug under ref semantics.
|
||||
##
|
||||
## NOTE: this deliberately deviates from the AGENTS.md `Ref`-suffix naming
|
||||
## rule; renaming the ~2,100 occurrences of `WakuMessage` has no semantic
|
||||
## value and would be pure churn.
|
||||
payload*: seq[byte]
|
||||
# String identifier that can be used for content-based filtering.
|
||||
contentTopic*: ContentTopic
|
||||
@ -28,7 +41,36 @@ type WakuMessage* = object # Data payload transmitted.
|
||||
# attribute will be used in the rln-relay protocol.
|
||||
proof*: seq[byte]
|
||||
|
||||
proc `==`*(a, b: WakuMessage): bool =
|
||||
## Structural equality. Two messages are equal when both are nil, or both are
|
||||
## non-nil with field-wise-equal contents (ref identity is *not* used).
|
||||
if a.isNil() or b.isNil():
|
||||
return a.isNil() and b.isNil()
|
||||
a.payload == b.payload and a.contentTopic == b.contentTopic and a.meta == b.meta and
|
||||
a.version == b.version and a.timestamp == b.timestamp and a.ephemeral == b.ephemeral and
|
||||
a.proof == b.proof
|
||||
|
||||
proc clone*(msg: WakuMessage): WakuMessage =
|
||||
## Explicit deep copy into a fresh ref (copies the payload/meta/proof seqs).
|
||||
## Cloning is the deliberate, rare escape hatch when a held message must be
|
||||
## mutated without affecting other holders.
|
||||
if msg.isNil():
|
||||
return nil
|
||||
WakuMessage(
|
||||
payload: msg.payload,
|
||||
contentTopic: msg.contentTopic,
|
||||
meta: msg.meta,
|
||||
version: msg.version,
|
||||
timestamp: msg.timestamp,
|
||||
ephemeral: msg.ephemeral,
|
||||
proof: msg.proof,
|
||||
)
|
||||
|
||||
proc ensureTimestampSet*(message: WakuMessage): WakuMessage =
|
||||
result = message
|
||||
if result.timestamp == 0:
|
||||
result.timestamp = getNowInNanosecondTime()
|
||||
## Returns `message` unchanged when its timestamp is already set; otherwise
|
||||
## returns a fresh clone with the current timestamp. Never mutates the input
|
||||
## (which may be shared under ref semantics).
|
||||
if message.timestamp != 0:
|
||||
return message
|
||||
result = message.clone()
|
||||
result.timestamp = getNowInNanosecondTime()
|
||||
|
||||
@ -689,9 +689,7 @@ proc publish*(
|
||||
if pubsubTopic.isEmptyOrWhitespace():
|
||||
return err(NoTopicSpecified)
|
||||
|
||||
var message = wakuMessage
|
||||
if message.timestamp == 0:
|
||||
message.timestamp = getNowInNanosecondTime()
|
||||
let message = wakuMessage.ensureTimestampSet()
|
||||
|
||||
let data = message.encode().buffer
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import ./test_waku
|
||||
import
|
||||
./waku_core/test_namespaced_topics,
|
||||
./waku_core/test_time,
|
||||
./waku_core/test_message,
|
||||
./waku_core/test_message_digest,
|
||||
./waku_core/test_peers,
|
||||
./waku_core/test_published_address
|
||||
|
||||
72
tests/waku_core/test_message.nim
Normal file
72
tests/waku_core/test_message.nim
Normal file
@ -0,0 +1,72 @@
|
||||
{.used.}
|
||||
|
||||
import std/times, stew/byteutils, testutils/unittests
|
||||
import logos_delivery/waku/waku_core/message/message
|
||||
|
||||
suite "Waku Message - ref semantics":
|
||||
test "structural == on equal contents":
|
||||
let a = WakuMessage(
|
||||
payload: "\x01\x02\x03".toBytes(),
|
||||
contentTopic: "/test/1/proto",
|
||||
meta: @[byte 9],
|
||||
version: 1,
|
||||
timestamp: 42,
|
||||
ephemeral: true,
|
||||
proof: @[byte 7],
|
||||
)
|
||||
let b = WakuMessage(
|
||||
payload: "\x01\x02\x03".toBytes(),
|
||||
contentTopic: "/test/1/proto",
|
||||
meta: @[byte 9],
|
||||
version: 1,
|
||||
timestamp: 42,
|
||||
ephemeral: true,
|
||||
proof: @[byte 7],
|
||||
)
|
||||
check:
|
||||
a == b # equal by content, distinct refs
|
||||
not (a == WakuMessage(payload: "\x01\x02\x03".toBytes()))
|
||||
|
||||
test "structural == nil cases":
|
||||
let nilA: WakuMessage = nil
|
||||
let nilB: WakuMessage = nil
|
||||
let nonNil = WakuMessage(payload: @[byte 1])
|
||||
check:
|
||||
nilA == nilB # both nil -> equal
|
||||
not (nilA == nonNil) # one nil -> not equal
|
||||
not (nonNil == nilB)
|
||||
|
||||
test "clone is an independent deep copy":
|
||||
let orig =
|
||||
WakuMessage(payload: @[byte 1, 2, 3], contentTopic: "/a/1", proof: @[byte 8])
|
||||
let cp = orig.clone()
|
||||
check:
|
||||
cp == orig # equal contents
|
||||
not cp.isNil()
|
||||
# mutating the clone must not touch the original
|
||||
cp.payload = @[byte 9]
|
||||
cp.proof = @[byte 0]
|
||||
check:
|
||||
orig.payload == @[byte 1, 2, 3]
|
||||
orig.proof == @[byte 8]
|
||||
not (cp == orig)
|
||||
|
||||
test "clone(nil) is nil":
|
||||
let n: WakuMessage = nil
|
||||
check n.clone().isNil()
|
||||
|
||||
test "ensureTimestampSet returns same ref when timestamp already set":
|
||||
let msg = WakuMessage(payload: @[byte 1], timestamp: 123)
|
||||
let res = msg.ensureTimestampSet()
|
||||
check:
|
||||
cast[pointer](res) == cast[pointer](msg) # same instance, no clone
|
||||
res.timestamp == 123
|
||||
|
||||
test "ensureTimestampSet returns a fresh ref, never mutating the shared input":
|
||||
let msg = WakuMessage(payload: @[byte 1], timestamp: 0)
|
||||
let res = msg.ensureTimestampSet()
|
||||
check:
|
||||
cast[pointer](res) != cast[pointer](msg) # distinct instance
|
||||
res.timestamp != 0
|
||||
msg.timestamp == 0 # input untouched (would be a shared-state bug)
|
||||
res.payload == msg.payload
|
||||
Loading…
x
Reference in New Issue
Block a user