logos-delivery/docs/analysis/plan_phase2_wakumessage_ref.md
NagyZoltanPeter 05ac82d454
docs: async copy analysis and 3-phase elimination plan
Analysis of chronos/refc deep-copy behavior on the WakuMessage path
(~19+F payload copies, 4-6 SHA-256 passes per inbound relayed message)
plus executor plans for: perf harness, WakuMessage ref object, WakuEnvelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 04:07:59 +02:00

128 lines
7.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Phase 2 — `WakuMessage` becomes a `ref object` (executor plan)
> Self-contained implementation plan. Context: [async_copy_analysis.md](async_copy_analysis.md),
> [async_copy_fix_plan.md](async_copy_fix_plan.md). Prerequisite: Phase 1 benchmark exists
> and `docs/analysis/bench_baseline.md` is recorded.
>
> **Objective**: change `WakuMessage` from a value `object` to a `ref object` so every
> assignment, async-closure capture, and Result/Option bind-out of a message costs a
> pointer + refcount instead of deep-copying 3 seqs + a string. No proc signatures change.
> Expected result (verify with Phase 1 bench): ~5060 % reduction in alloc volume per
> message; decode/hash counters unchanged.
>
> **The danger of this phase is not the diff size — it is aliasing.** Code written under
> value semantics may mutate "its copy" of a message; under ref semantics that mutation is
> now shared with every other holder. Every mutation site must be found and either cloned
> or proven to own a freshly constructed message.
## Constraints
Same as Phase 1 (refc, nph, gitnexus impact/detect_changes, no push, verify by running).
Additionally: run `gitnexus_impact({target: "WakuMessage", direction: "upstream"})` first
and report the blast radius before editing.
## Step 1 — The type change (`logos_delivery/waku/waku_core/message/message.nim`)
Current (lines 1229): `type WakuMessage* = object` with `payload: seq[byte]`,
`contentTopic: ContentTopic`, `meta: seq[byte]`, `version: uint32`,
`timestamp: Timestamp`, `ephemeral: bool`, `proof: seq[byte]`.
1. `type WakuMessage* = ref object` — construction syntax `WakuMessage(payload: ...)` is
unchanged, so the 27 src + ~1,324 test constructions (1,174 via `fakeWakuMessage`,
`tests/testlib/wakucore.nim:74`) compile as-is.
2. Add a **structural `==`** (~12 LOC): both-nil → true, one-nil → false, else field-wise
compare. Without this, ~107 test assertions comparing messages break on ref identity.
3. Add `proc clone*(msg: WakuMessage): WakuMessage` — field-wise copy into a fresh ref
(deep-copies the seqs; that is the point: cloning is now *explicit and rare*).
4. **Fix `ensureTimestampSet` (lines 3134).** Current body `result = message;
result.timestamp = ...` aliases the input under ref semantics and mutates the *shared*
message. Rewrite non-mutating:
```nim
proc ensureTimestampSet*(message: WakuMessage): WakuMessage =
if message.timestamp != 0:
return message
var m = message.clone()
m.timestamp = getNowInNanosecondTime()
m
```
5. Doc comment at the type: messages are **immutable by convention after construction**;
mutate only freshly constructed or explicitly `clone()`d instances. Also note the
deliberate deviation from the AGENTS.md `Ref`-suffix rule (renaming ~2,100 occurrences
has no semantic value).
## Step 2 — Known aliasing sites (fix, don't just audit)
1. **Relay publish** `logos_delivery/waku/waku_relay/protocol.nim:680-682`:
```nim
var message = wakuMessage
if message.timestamp == 0:
message.timestamp = getNowInNanosecondTime()
```
Under ref this mutates the caller's message. Replace with
`let message = wakuMessage.ensureTimestampSet()` (safe after Step 1.4).
2. **RLN proof attachment** — search `logos_delivery/waku/waku_rln_relay/` for procs taking
`msg: var WakuMessage` or assigning `msg.proof = ` (e.g. `appendRLNProof`). These run on
the *outbound* path on a message the API caller still holds. Decision rule: if the
message came in through a public API, `clone()` before mutating (or restructure to
build-then-assign locally).
3. **Sweep for every other field mutation**:
`grep -rn --include='*.nim' '\.\(payload\|meta\|proof\|timestamp\|ephemeral\|version\|contentTopic\) *=' logos_delivery library apps`
Classify each hit: (a) freshly constructed in the same scope (e.g. the protobuf decode
at `waku_core/message/codec.nim:23` building `var msg = WakuMessage()`) → fine;
(b) received/parameter/stored message → `clone()` first or restructure. Record the
classification of every hit in the phase notes (this list is the review artifact).
## Step 3 — Nil-safety audit
`var m: WakuMessage` now defaults to `nil`, not an empty object; field access on it crashes.
- `grep -rn --include='*.nim' 'var [a-zA-Z_]*: WakuMessage\b\|: WakuMessage$' logos_delivery library apps` —
any declaration relying on default-init must become `= WakuMessage()`.
- Check emptiness idioms: `== WakuMessage()` / `== default(WakuMessage)` comparisons and
`Table[..., WakuMessage].getOrDefault` consumers (getOrDefault now yields `nil`).
- Fields of type `WakuMessage` inside other objects (e.g. `MessagePush.wakuMessage` in
`waku_filter_v2/rpc.nim`, store RPC types, `queue_driver`'s
`SortedSet[Index, WakuMessage]`) — their decode paths must construct explicitly; verify
each codec assigns a constructed message (`rpc_codec.nim` files for filter/lightpush/store).
- `Option[WakuMessage]`/`Opt[WakuMessage]` (only 2 src occurrences): `none`/absent now also
representable as nil ref — do NOT collapse the two; keep the Opt wrapper semantics as-is.
## Step 4 — Tests
- Full `make test`. Expected failure classes: nil-default (Step 3 misses), ref-identity
assumptions (tests mutating a fixture then asserting the original unchanged — now shared),
and `check msg1 == msg2` (covered by structural `==`).
- Do NOT weaken test assertions to make them pass; fix the production/nil issue instead.
- Add new unit tests in `tests/waku_core/` (~40 LOC): structural `==` incl. nil cases;
`clone()` independence (mutating clone leaves original untouched); `ensureTimestampSet`
returns same ref when timestamp set / fresh ref when not.
## Step 5 — Memory-model verification (mandatory before "done")
- refc: full suite + the Phase 1 benchmark; record the checkpoint numbers next to
`bench_baseline.md` (expect alloc-volume drop, identical decode/hash counters, identical
message throughput or better).
- ASAN run of the message-path tests (clang, refc) — this is the net that catches
a use-after-free from the new shared lifetimes: at minimum
`tests/waku_relay`, `tests/waku_archive`, `tests/waku_filter_v2`, `tests/node`.
- Statement for the record (per FFI rules): `library/` C bindings deep-copy message fields
via `copyMem` into JSON events (`library/json_message_event.nim:72-95`), so no message
ref crosses the FFI boundary; refc vs ORC behavior of the ref itself is standard RC —
no finalizers, no cycles (WakuMessage has no back-references).
## Acceptance criteria
1. Full `make test` green; ASAN run clean.
2. Step 2.3 mutation-site classification list produced (in PR description or phase notes).
3. Phase 1 bench re-run recorded: alloc volume down ≥ 40 % (projection is 5060 %),
decode/hash counters unchanged vs baseline. If alloc volume barely moves, something
still deep-copies — investigate before closing the phase (prime suspects: a `var`
parameter forcing a copy, or a leftover value-type field of WakuMessage inside a
container that the compiler still copies).
4. `gitnexus_detect_changes()` reviewed; commit (no push).
## Estimated diff
~35 new LOC, ~160340 changed LOC (dominated by Steps 2.3/3 fixes and test adjustments).
Budget: 23 days, most of it audit + verification, not typing.