Files
qortal/.claude/RNS_analysis.md
2026-08-11 17:06:41 +02:00

23 KiB
Raw Permalink Blame History

RNS.java — Structural Analysis

File: src/main/java/org/qortal/network/RNS.java Analysed at: commit 4ec4609a (branch HEAD, detached) Scope: analysis only — no code was changed.


1. Metrics

Metric Value
Total lines 2611
Blank 168
Comment / javadoc lines 743 (28%)
Effective code lines ~1700
Lines that are commented-out code (contain (, ; or {) ~344 (13% of file)
Methods 68
Lines inside methods 1741
Fields 55+ (incl. 4 executors, 2 threads, 8 volatile timers, 6 collections)
Inner classes 3 (QAnnounceHandler, AnnounceInfo, SingletonContainer) + ~100 lines of commented-out RNSProcessor

Longest methods:

Lines Method Line
245 runBaseLoop 567
155 prunePeers 2180
154 runDataLoop 819
89 shutdown 1020
87 maybeAddDynamicGateway 1464
83 start 396
77 initConfig 484
44 maybeAnnounce 2336
43 constructor 350

runBaseLoop alone is nesting depth 7 in places (while → try → if → if → try → lambda → try → for → try).

Context: this is the second-largest class in org.qortal.network after Network.java (3432) and NetworkData.java (2736); ReticulumPeer.java is 1633.


2. Responsibilities currently inside one class

RNS is a god class. At least nine independent concerns live here, each with its own lifecycle, its own state, and (mostly) no need to know about the others:

# Concern Members involved Approx. LOC
1 Reticulum config file generation (Jinjava templating, fallback config, FQDN, gateway server lists) initConfig ~80
2 Node identity + destination lifecycle (identity load/create/persist, baseDestination, dataDestination, proof strategy, callbacks) start, serverIdentity, baseDestination, dataDestination ~90
3 Announce appData codec (QAN1 TLV encode/decode, legacy QGW1 fallback, version packing) buildAnnounceAppData, buildGatewayValue, decodeGatewayAppData, decodeAnnounceAppData, AnnounceInfo, parseVersionToLong, QAN_*/GW_* constants ~140
4 Gateway discovery / dynamic interface management maybeAddDynamicGateway, isUsableAdvertiseHost, getAdvertiseHost, getLocalFqdn, recentGatewayAttempts, maybeForceBackboneReconnect, GATEWAY_COOLDOWN ~160
5 Peer registry (two lists × two mirrors, add/remove, identity dedup, snapshot rebuild) linkedPeers, immutableLinkedPeers, incomingPeers, immutableIncomingPeers, addLinkedPeer, removeLinkedPeer, addIncomingPeer, removeIncomingPeer, dedupIncomingPeerByIdentity, onIncomingPeerIdentified, closeIfActive, find* ~230
6 Known-peer persistence (2 files, 4 sets, 4 methods — duplicated per aspect) save/loadKnownPeerHashes, save/loadKnownDataPeerHashes, confirmPeerHash, known*/loaded* sets ~90
7 Reconnect policy / backoff state machine pendingLinkFailureMs, pendingDataLinkFailureMs, pendingFailureCount, recordPendingFailure, pendingBackoffMs, clearPendingFailure, reconnect bodies inside both loops ~180
8 Scheduling engine (2 polling threads, 4 single-thread executors, 1 worker pool, watchdogs, circuit breaker) runBaseLoop, runDataLoop, all *TaskStartedMs/*TaskFuture/last*Ms volatiles, triggerImmediate*Announce ~420
9 Qortal protocol surface (broadcast, ping/pong, message magic, chain-tip message building, peer misbehaviour) broadcast, onPingMessage, onPeersV2Message, buildHeightOrChainTipInfo, getMessageMagic, peerMisbehaved, getAllKnown*Peers, getActiveDataPeers ~130

Concerns 1, 3 and 4 are pure logic with no dependency on peer state at all — they are stuck inside a class whose construction spins up an entire Reticulum stack, which makes them effectively untestable today.


3. The dominant structural problem: BASE/DATA duplication

Almost every piece of state and logic exists twice, once per aspect, copy-pasted rather than parameterised:

BASE DATA
rnsBaseThread / runBaseLoop() (245 L) rnsDataThread / runDataLoop() (154 L)
announceExecutor, reconnectExecutor dataAnnounceExecutor, dataReconnectExecutor
lastBaseLoopAnnounceMs, lastBaseLoopReconnectMs lastDataLoopAnnounceMs, lastDataLoopReconnectMs
announceTaskStartedMs, reconnectTaskStartedMs dataAnnounceTaskStartedMs, dataReconnectTaskStartedMs
announceTaskFuture, reconnectTaskFuture dataAnnounceTaskFuture, dataReconnectTaskFuture
knownPeerHashes, loadedPeerHashes, KNOWN_PEERS_FILE knownDataPeerHashes, loadedDataPeerHashes, KNOWN_DATA_PEERS_FILE
pendingLinkFailureMs pendingDataLinkFailureMs
saveKnownPeerHashes, loadKnownPeerHashes saveKnownDataPeerHashes, loadKnownDataPeerHashes
createLinkedPeerFromIdentity createLinkedDataPeerFromIdentity
baseClientConnected dataClientConnected
triggerImmediateAnnounce triggerImmediateDataAnnounce
MIN_DESIRED_CORE_PEERS, CORE_ASPECT MIN_DESIRED_DATA_PEERS, QDN_ASPECT
BASE_LOOP_*_INTERVAL_MS DATA_LOOP_*_INTERVAL_MS

That is roughly 600 lines that are one parameterised abstraction away from being ~320.

Worse, the copies have already drifted, which is exactly the failure mode duplication produces:

  • runDataLoop has no watchdog. runBaseLoop interrupts a stuck announce/reconnect task after 60 s/45 s and force-reconnects the backbone; runDataLoop only checks dataAnnounceTaskStartedMs == 0L. If a DATA announce task ever wedges inside Transport.outbound(), dataAnnounceTaskStartedMs stays non-zero forever and DATA announces stop permanently for the process lifetime. Same for DATA reconnect.
  • runDataLoop has no consecutiveStuckTasks participation and no maybeForceBackboneReconnect().
  • The BASE reconnect path has the "1 outgoing link per cycle when disconnected" throttle and the precomputed activeIncomingBaseHashes O(1) dedup set; the DATA path has neither — it can create N simultaneous outgoing links and never skips peers already ACTIVE as incoming.
  • maybeAnnounce() (44 L) is two near-identical if blocks differing only in the count variable and log prefix.

This drift is the single strongest argument for the split: the DATA path is silently missing hard-won robustness fixes that were applied to BASE.


4. Lombok @Data on a stateful singleton

@Data on line 113 generates, for all 55+ fields:

  • toString() over every field — including linkedPeers, incomingPeers, both immutable mirrors, reticulum, serverIdentity, all executors. Any accidental log.info("{}", rns) iterates every peer (each ReticulumPeer has its own @Data-ish toString) while other threads mutate the lists — expensive, and an unsynchronised iteration.
  • equals()/hashCode() over every field, on a singleton where identity comparison is the only meaningful semantic. hashCode() on a live ExecutorService/Reticulum/peer-list graph is meaningless and expensive.
  • Public setters for everything: setReticulum(), setServerIdentity(), setBaseDestination(), setLinkedPeers(), setShuttingDown(), setMeshStarted()… all reachable from any caller. The carefully-guarded invariants in addLinkedPeer/removeLinkedPeer can be bypassed by a single setImmutableLinkedPeers(...).
  • Getters for private internals (getPendingLinkFailureMs(), getKnownPeerHashes(), …) that were never intended to be API.

The actual external surface is small — 16 distinct methods across the whole codebase:

getActiveDataPeers(5) markPeerForImmediateRemoval(4) isMeshStarted(4) isShuttingDown(3)
getImmutableLinkedPeers(3) start(2) onPeersV2Message(2) getServerIdentity(2)
triggerImmediateAnnounce shutdown isUnreachable getBaseDestination getAllKnownPeers
dedupIncomingPeerByIdentity confirmPeerHash broadcast

So @Data is exporting ~100 accessors to serve 16 real call sites. Replacing it with explicit @Getter on the handful of fields that need it (or an extracted interface) is a large, near-zero-risk reduction in surface.


5. Correctness / concurrency observations

These are observations from reading the code; each would need runtime confirmation before being treated as a live defect. Listed roughly by severity.

5.1 shutdown() uses bitwise & instead of && (line 1052)

if (nonNull(pl) & (pl.getStatus() == ACTIVE)) {

& does not short-circuit, so pl.getStatus() is evaluated even when pl == nullNullPointerException during shutdown as soon as any incoming peer has a null link. The NPE propagates out of shutdown(), skipping the linked-peer shutdown, the executor shutdown and exitHandler(). removePeer/prunePeers elsewhere in the same file correctly use &&.

5.2 Immutable-snapshot rebuild is not atomic on the remove path

addLinkedPeer (1907) mutates and rebuilds inside synchronized (this.linkedPeers). removeLinkedPeer (1952) and removeIncomingPeer (2103) do not:

this.linkedPeers.remove(peer);                        // internally synchronized
this.immutableLinkedPeers = List.copyOf(this.linkedPeers);  // separate, unguarded

Interleaving: remover reads the backing list → adder (holding the lock) adds a peer and publishes snapshot {P2} → remover publishes its stale snapshot {}. The new peer is now in linkedPeers but invisible in immutableLinkedPeers until the next mutation. Every consumer (broadcast, runBaseLoop, prunePeers, getActiveImmutableLinkedPeers, findPeerBy*) reads the snapshot, so the peer is effectively dead while still holding a live link.

5.3 Unsynchronised iteration over Collections.synchronizedList

getNonActiveIncomingPeers() (2163) does var ips = getIncomingPeers(); — the Lombok getter hands back the live synchronized list, not a copy — then for-each over it without holding its monitor. ConcurrentModificationException is possible whenever addIncomingPeer/removeIncomingPeer runs concurrently. prunePeers() calls it three times per cycle; shutdown() has the same pattern over both raw lists (1050, 1058).

5.4 receivedAnnounce does network I/O under a lock, on the Transport thread

QAnnounceHandler.receivedAnnounce is @Synchronized and calls maybeAddDynamicGateway(), which constructs a BackboneClientInterface, runs InterfaceUtils.initIFac() and calls iface.launch() — i.e. TCP connect work — while holding the handler lock, on Reticulum's announce-delivery thread. A slow/unreachable announced gateway stalls announce processing. Also note the two handler instances have separate Lombok $locks, so the synchronisation does not actually serialise BASE against DATA — it is unclear what it is protecting.

5.5 Unbounded maps

  • recentGatewayAttempts — one entry per host:port ever seen, never evicted (cooldown is compared, entries are never removed).
  • pendingLinkFailureMs, pendingDataLinkFailureMs, pendingFailureCount — cleared only on confirmPeerHash success. A peer that fails forever and never returns keeps its entries forever.

announcedVersions is correctly bounded (LRU, 512) — that is the pattern the others should follow.

5.6 Constructor swallows failure, leaving a half-built singleton

The constructor catches IOException from new Reticulum(...), logs, and continues — leaving reticulum == null. start() then dereferences reticulum.getStoragePath() immediately (line 399) → NPE. Meanwhile getInstance() has already published the broken instance, and isMeshStarted() stays false forever with no diagnostic beyond the original log line. There is no reticulumEnabled setting, so RNS.getInstance() from Controller (line 995) always triggers this heavy construction.

5.7 Semantic naming issues

  • getAllKnownPeers() / getAllKnownCorePeers() / getAllKnownDataPeers() return only currently-active initiator peers — not "all known", and they omit incoming peers entirely. Network.getAllKnownPeers() (Network.java:807) merges this in, so the name mismatch propagates.
  • isUnreachable() returns boxed Boolean; every call site auto-unboxes.
  • maybeRecoverInstance() is an empty TODO stub, public.

5.8 Minor

  • peerMisbehaved uses Class.forName("org.qortal.network.ReticulumPeer").isInstance(peer) with a ClassNotFoundException handler, instead of peer instanceof ReticulumPeer.
  • QAnnounceHandler compares aspects with string literals "qortal.qdn" / "qortal.core" (lines 1629, 1679, 1560) although CORE_ASPECT/QDN_ASPECT constants exist; and new String(aspectFilter) copies a string for no reason.
  • Redundant state setting: createLinkedPeerFromIdentity calls both setPeerAspect(BASE) and setIsDataPeer(false), but ReticulumPeer.setIsDataPeer is a setPeerAspect wrapper. Same in createLinkedDataPeerFromIdentity and getNewPeer.
  • maybeAnnounce announces when count <= MIN_DESIRED, i.e. it keeps announcing after the target is met.
  • Fully-qualified names used inline (java.util.concurrent.ConcurrentHashMap, io.reticulum.interfaces.backbone.BackboneClientInterface, java.time.Duration) even where the import already exists — a symptom of the file having grown past the point where anyone reads the import block.

6. Performance observations

  1. Two 100 Hz polling threads. Both loops end with Thread.sleep(10) unconditionally. Per iteration each loop builds: a Stream.concat of two streams, 24 lambda filters, and a collect(toList()). That is ~200 peer-list traversals + collector allocations per second at complete idle, in exchange for at most 10 ms of latency improvement over a 100 ms tick. A blocking hand-off (peer message availability signalled via a queue/condition) or simply a 50100 ms sleep would remove nearly all of it.

  2. getActiveImmutableLinkedPeers() allocates on every call (1851): builds a new ArrayList<>() wrapped in Collections.synchronizedList for a value that is immediately returned to a single caller and never shared — pure overhead, and misleading (the wrapper implies thread-safety that the returned snapshot does not need). It is called once per loop iteration in both loops (~200/s), plus 4× per prunePeers(), plus in broadcast, maybeAnnounce, getActiveDataPeers, getAllKnown*Peers.

  3. prunePeers() recomputes the same views repeatedly: getActiveImmutableLinkedPeers() at 2185, 2190, 2324, 2328; getNonActiveIncomingPeers() at 2187, 2269, 2326 — each a fresh O(n) traversal + allocation. Line 2185's initiatorActivePeerList and 2270's incomingPeerList are assigned and never used (dead locals).

  4. INFO-level logging in hot paths. Per reconnect cycle (every 15 s) the BASE task logs one INFO line per interface, then two INFO lines per known-peer hash ("Path to X: hops=…" plus the strategy line). With 50 known peers that is ~100 INFO lines every 15 s = ~24 k lines/hour from one loop. findPeerByLink/findPeerByDestinationHash log at INFO on every match.

  5. List.copyOf on every add/remove is fine for the current peer counts, but combined with dedupIncomingPeerByIdentity (which submits a task per identification) it makes registry mutation O(n) per event.


7. Dead weight

Item Detail
Commented-out RNSProcessor inner class lines 17431840 (~100 lines)
Commented-out broadcastOurChain, buildNewTransactionMessage, buildGetUnconfirmedTransactionsMessage 9961018
Commented-out makePeerAvailable/makePeerUnavailable/removePeer/getter blocks 18911902, 19381944, 19651971, 21102116, 25722577
Commented-out import lines ~25 in the header block
Unused fields MAX_PEERS (126), reticulumMaxNetworkThreadPoolSize (228 — the constructor reads Settings directly instead), PRUNE_INTERVAL (130, referenced only from commented code), BROADCAST_INTERVAL (239, ditto)
Unused imports SelectionKey, AtomicLong, Predicate, TransactionData, BlockData (each appears only in the import + commented code)
Duplicated javadoc buildAnnounceAppData has two stacked javadoc blocks (11911210), the first describing the superseded QGW1 format; dedupIncomingPeerByIdentity likewise has two (20072028), the first attached to the wrong method
Dead locals initiatorActivePeerList (2185, 2324), incomingPeerList = this.incomingPeers (2270)

Removing all of this is mechanical and drops the file by roughly 350400 lines with zero behavioural change.


8. Proposed decomposition

The organising idea: make "aspect" a first-class object instead of a copy-paste axis, and lift the three stateless concerns (config, codec, gateway) out entirely.

graph TD
    RNS["RNS (facade, ~250 L)<br/>singleton, lifecycle, public API"]
    CFG["RNSConfigWriter (~90 L)<br/>Jinjava render, fallback config"]
    CODEC["RNSAnnounceCodec (~150 L)<br/>QAN1 TLV encode/decode, QGW1 fallback,<br/>version packing — pure, static"]
    GW["RNSGatewayManager (~180 L)<br/>advertise-host resolution, dynamic<br/>BackboneClientInterface, cooldown,<br/>forced backbone reconnect"]
    REG["RNSPeerRegistry (~250 L)<br/>linked+incoming lists, snapshots,<br/>identity dedup, lookups"]
    STORE["KnownPeerStore (~70 L)<br/>one instance per aspect,<br/>load/save/confirm hashes"]
    BACKOFF["ReconnectPolicy (~90 L)<br/>failure counts, capped exp backoff"]
    ASPECT["RNSAspectRunner (~320 L)<br/>ONE instance per aspect:<br/>loop, announce+reconnect executors,<br/>watchdogs, circuit breaker"]
    PRUNE["RNSPeerPruner (~160 L)<br/>prunePeers, isUnreachable"]
    MSG["RNSMessaging (~120 L)<br/>broadcast, ping/pong, message magic,<br/>chain-tip message building"]

    RNS --> CFG
    RNS --> GW
    RNS --> REG
    RNS --> ASPECT
    RNS --> PRUNE
    RNS --> MSG
    ASPECT --> REG
    ASPECT --> STORE
    ASPECT --> BACKOFF
    ASPECT --> CODEC
    ASPECT --> GW
    GW --> CODEC
    PRUNE --> REG
    PRUNE --> BACKOFF
    MSG --> REG

8.1 RNSAnnounceCodec (highest value, lowest risk)

Pure functions, no state, no Reticulum dependency: buildAnnounceAppData(version, gatewayHostOrNull, port), decode(byte[]) → AnnounceInfo, parseVersionToLong, isUsableAdvertiseHost. Make AnnounceInfo a package-private record/immutable class.

Payoff: the QAN1/QGW1 wire format becomes unit-testable for the first time (round-trip, truncation, unknown-TLV skip, legacy fallback, oversized host, port bounds). Today none of that can be tested without constructing RNS, which builds a Reticulum stack and 5 thread pools. RNSNetworkTest.java exists but cannot reach any of this.

8.2 RNSAspectRunner (highest structural value)

One class, instantiated twice:

new RNSAspectRunner(PeerAspect.BASE, baseDestination, CORE_ASPECT,
                    MIN_DESIRED_CORE_PEERS, knownBaseStore, registry, policy)
new RNSAspectRunner(PeerAspect.DATA, dataDestination, QDN_ASPECT,
                    MIN_DESIRED_DATA_PEERS, knownDataStore, registry, policy)

Each instance owns: its thread, its two single-thread executors, its interval/watchdog timers, its known-peer store, its createLinkedPeerFromIdentity, its triggerImmediateAnnounce. The activeIncomingHashes precomputation, the one-link-per-cycle throttle, the watchdog and the circuit breaker are written once and therefore apply to both aspects — closing the drift documented in §3. The only genuinely aspect-specific thing left is the Peer.NETWORK vs Peer.NETWORKDATA message-task selector, which is one constructor parameter.

Estimated reduction: ~600 lines → ~320.

8.3 RNSPeerRegistry

Owns the four collections and is the only place that mutates them. Fixes §5.2 and §5.3 by construction: every mutation takes a single lock and rebuilds the snapshot inside it; every read returns the immutable snapshot (never the live list). getActivePeers(aspect) returns a plain List.copyOf/unmodifiableList, not a pointless synchronizedList.

8.4 KnownPeerStore

One instance per aspect, parameterised by filename and set. Collapses 4 methods + 4 sets + 2 filename constants into one ~70-line class instantiated twice.

8.5 RNSGatewayManager

maybeAddDynamicGateway, getAdvertiseHost, getLocalFqdn, recentGatewayAttempts (with eviction), maybeForceBackboneReconnect. Enables the fix for §5.4 — dialling can be moved off the announce thread onto an executor owned by this class without touching the announce path.

8.6 RNSPeerPruner + RNSMessaging

Mechanical lifts. prunePeers() in particular is 155 lines of four unrelated passes (initiator prune / non-active incoming / identity dedup / silent-ACTIVE incoming) that become four short private methods.

8.7 What RNS keeps

Singleton access, start()/shutdown() orchestration, identity + destination creation, QAnnounceHandler registration, and the ~16-method public façade actually used by Controller, Network, NetworkData, PeersResource, ConnectedPeer, ArbitraryDataFileManager and ReticulumPeer. Target: ~250 lines.


9. Suggested sequencing

Ordered so that each phase is independently shippable and testable, risk increasing down the list.

Phase Work Risk Δ lines
0 Delete commented-out code, unused fields/imports, dead locals, duplicated javadoc none 350…−400
1 Replace @Data with explicit @Getters on the ~8 fields that need them; verify against the 16-call-site list very low ~0 (large API-surface reduction)
2 Fix &&& in shutdown(); make getNonActiveIncomingPeers copy before iterating very low ~0
3 Extract RNSAnnounceCodec (+ unit tests) very low — pure functions 140 from RNS
4 Extract KnownPeerStore, instantiate ×2 low 60
5 Extract RNSGatewayManager low 160
6 Extract RNSPeerRegistry; make snapshot rebuild atomic on all paths medium — touches every peer mutation 230
7 Extract RNSPeerPruner, RNSMessaging lowmedium 280
8 Unify runBaseLoop/runDataLoop into RNSAspectRunner highest — but this is where the DATA-path robustness gap gets closed 280
9 Reduce poll frequency / stop allocating in getActiveImmutableLinkedPeers; demote hot-path INFO logs to DEBUG low small

Phases 05 are ~2/3 of the line reduction at near-zero behavioural risk, and can land before any decision is made about phases 68.


10. Caveats

  • The concurrency comments throughout this file (jobsLock contention, expirePath() cull cascades, watchdog thread leaks, the ABBA inversion avoided in closeIfActive) encode real, painfully-acquired operational knowledge. Any refactor must carry those comments across verbatim — several of them are the only record of why an obvious-looking simplification is wrong (e.g. why PENDING links must not be torn down, why removeLinkedPeer deliberately does not close peerLink).
  • RNS is reachable from Controller, Network, NetworkData, PeersResource, ConnectedPeer, ArbitraryDataFileManager and ReticulumPeer. Only 16 methods are used externally, so the façade can stay source-compatible throughout — but @Data currently exposes ~100 accessors, so phase 1 should be verified by compilation across the whole tree, not by inspection of org.qortal.network alone.
  • Items in §5 are read-only findings. Before treating any as a live bug, confirm against logs/runtime — in particular §5.2, whose window is narrow.
  • Create a reticulum folder under networking and put all reticulum java files