2026-03-09 14:57:41 +01:00
@startuml indexer_heartbeat
2026-03-11 16:28:15 +01:00
title Heartbeat bidirectionnel node → indexeur (scoring 7 dimensions + challenges)
2026-02-24 14:31:37 +01:00
participant "Node A" as NodeA
participant "Node B" as NodeB
2026-03-09 14:57:41 +01:00
participant "IndexerService" as Indexer
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
note over NodeA,NodeB: SendHeartbeat goroutine — tick every 20s
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
== Tick Node A ==
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
NodeA -> Indexer: NewStream /opencloud/heartbeat/1.0\n(long-lived, réutilisé aux ticks suivants)
NodeA -> Indexer: stream.Encode(Heartbeat{\n name, PeerID_A, timestamp,\n indexersBinded: [addr1, addr2],\n need: maxPool - len(pool),\n challenges: [PeerID_A, PeerID_B], ← batch (tous les 1-10 HBs)\n challengeDID: "uuid-did-A", ← DHT challenge (tous les 5 batches)\n record: SignedPeerRecord_A ← expiry=now+2min\n})
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
Indexer -> Indexer: CheckHeartbeat(stream, maxNodes)\n→ len(Peers()) >= maxNodes → reject
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
Indexer -> Indexer: HandleHeartbeat → UptimeTracker.RecordHeartbeat()\n→ gap ≤ 2× interval : TotalOnline += gap
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
Indexer -> Indexer: Republish PeerRecord A to DHT\nDHT.PutValue("/node/"+DID_A, record_A)
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
== Réponse indexeur → node A ==
2026-03-09 14:57:41 +01:00
2026-03-11 16:28:15 +01:00
Indexer -> Indexer: BuildHeartbeatResponse(remotePeer=A, need, challenges, challengeDID)\n\nfillRate = connected_nodes / MaxNodesConn()\npeerCount = connected_nodes\nmaxNodes = MaxNodesConn()\nbornAt = time of indexer startup\n\nChallenges: pour chaque PeerID challengé\n found = PeerID dans StreamRecords[ProtocolHeartbeat]?\n lastSeen = HeartbeatStream.UptimeTracker.LastSeen\n\nDHT challenge:\n DHT.GetValue("/node/"+challengeDID, timeout=3s)\n → dhtFound + dhtPayload\n\nWitnesses: jusqu'à 3 AddrInfos de nœuds connectés\n (adresses connues dans Peerstore)\n\nSuggestions: jusqu'à `need` indexeurs depuis dhtCache\n (refresh asynchrone 2min, SelectByFillRate)\n\nSuggestMigrate: fillRate > 80%\n ET node dans offload.inBatch (batch ≤ 5, grace 3× HB)
2026-03-09 14:57:41 +01:00
2026-03-11 16:28:15 +01:00
Indexer --> NodeA: stream.Encode(HeartbeatResponse{\n fillRate, peerCount, maxNodes, bornAt,\n challenges, dhtFound, dhtPayload,\n witnesses, suggestions, suggestMigrate\n})
2026-03-09 14:57:41 +01:00
2026-03-11 16:28:15 +01:00
== Traitement score côté Node A ==
NodeA -> NodeA: score = ensureScore(Indexers, addr_indexer)\nscore.UptimeTracker.RecordHeartbeat()\n\nlatencyScore = max(0, 1 - RTT / (BaseRoundTrip × 10))\n\nBornAt stability:\n bornAt changed? → score.bornAtChanges++\n\nfillConsistency:\n expected = peerCount / maxNodes\n |expected - fillRate| < 10% → fillConsistent++\n\nChallenge PeerID (ground truth own PeerID):\n found=true AND lastSeen < 2× interval → challengeCorrect++\n\nDHT challenge:\n dhtFound=true → dhtSuccess++\n\nWitness query (async):\n go queryWitnesses(h, indexerID, bornAt, fillRate, witnesses, score)
2026-03-09 14:57:41 +01:00
2026-03-11 16:28:15 +01:00
NodeA -> NodeA: score.Score = ComputeNodeSideScore(latencyScore)\n\nScore = (\n 0.20 × uptimeRatio\n+ 0.20 × challengeAccuracy\n+ 0.15 × latencyScore\n+ 0.10 × fillScore ← 1 - fillRate\n+ 0.10 × fillConsistency\n+ 0.15 × witnessConsistency\n+ 0.10 × dhtSuccessRate\n) × 100 × bornAtPenalty\n\nbornAtPenalty = max(0, 1 - 0.30 × bornAtChanges)\nminScore = clamp(20 + 60 × (age.Hours/24), 20, 80)
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
alt score < minScore\n AND TotalOnline ≥ 2× interval\n AND !IsSeed\n AND len(pool) > 1
NodeA -> NodeA: evictPeer(dir, addr, id, proto)\n→ delete Addr + Score + Stream\ngo TriggerConsensus(h, voters, need)\n ou replenishIndexersFromDHT(h, need)
end
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
alt resp.SuggestMigrate == true AND nonSeedCount >= MinIndexer
alt IsSeed
NodeA -> NodeA: score.IsSeed = false\n(de-stickied — score eviction maintenant possible)
else !IsSeed
NodeA -> NodeA: evictPeer → migration acceptée
2026-02-24 14:31:37 +01:00
end
2026-03-11 16:28:15 +01:00
end
alt len(resp.Suggestions) > 0
NodeA -> NodeA: handleSuggestions(dir, indexerID, suggestions)\n→ inconnus ajoutés à Indexers Directory\n→ NudgeIt() si ajout effectif
end
== Tick Node B (concurrent) ==
NodeB -> Indexer: stream.Encode(Heartbeat{PeerID_B, ...})
Indexer -> Indexer: CheckHeartbeat → UptimeTracker → BuildHeartbeatResponse
Indexer --> NodeB: HeartbeatResponse{...}
== GC côté Indexeur ==
2026-02-24 14:31:37 +01:00
2026-03-11 16:28:15 +01:00
note over Indexer: GC ticker 30s — gc()\nnow.After(Expiry) où Expiry = lastHBTime + 2min\n→ AfterDelete(pid, name, did) hors lock\n→ publishNameEvent(NameIndexDelete, ...)\nFillRate recalculé automatiquement
2026-02-24 14:31:37 +01:00
@enduml