@startuml indexer_heartbeat
title Heartbeat bidirectionnel node → indexeur (scoring 7 dimensions + challenges)

participant "Node A" as NodeA
participant "Node B" as NodeB
participant "IndexerService" as Indexer

note over NodeA,NodeB: SendHeartbeat goroutine — tick every 20s

== Tick Node A ==

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})

Indexer -> Indexer: CheckHeartbeat(stream, maxNodes)\n→ len(Peers()) >= maxNodes → reject

Indexer -> Indexer: HandleHeartbeat → UptimeTracker.RecordHeartbeat()\n→ gap ≤ 2×interval : TotalOnline += gap

Indexer -> Indexer: Republish PeerRecord A to DHT\nDHT.PutValue("/node/"+DID_A, record_A)

== Réponse indexeur → node A ==

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)

Indexer --> NodeA: stream.Encode(HeartbeatResponse{\n  fillRate, peerCount, maxNodes, bornAt,\n  challenges, dhtFound, dhtPayload,\n  witnesses, suggestions, suggestMigrate\n})

== 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)

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)

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

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
    end
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 ==

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

@enduml
