From 83cef6e6f68a25a0b7497f26940b67a0d39e6010 Mon Sep 17 00:00:00 2001 From: mr Date: Mon, 9 Mar 2026 14:57:41 +0100 Subject: [PATCH] saved --- daemons/node/common/common_pubsub.go | 2 - daemons/node/common/common_stream.go | 249 +++- daemons/node/common/native_stream.go | 197 +++- daemons/node/indexer/handler.go | 37 + daemons/node/indexer/native.go | 34 +- daemons/node/indexer/service.go | 19 +- daemons/node/node.go | 1 - docs/DECENTRALIZED_SYSTEMS_COMPARISON.txt | 1030 +++++++++++++++++ docs/FUTURE_DHT_ARCHITECTURE.txt | 362 ++++++ docs/diagrams/01_node_init.mmd | 56 - docs/diagrams/01_node_init.puml | 28 +- docs/diagrams/02_node_claim.mmd | 38 - docs/diagrams/02_node_claim.puml | 20 +- docs/diagrams/03_indexer_heartbeat.mmd | 47 - docs/diagrams/03_indexer_heartbeat.puml | 58 +- docs/diagrams/04_indexer_publish.mmd | 41 - docs/diagrams/04_indexer_publish.puml | 24 +- docs/diagrams/05_indexer_get.mmd | 49 - docs/diagrams/05_indexer_get.puml | 32 +- docs/diagrams/06_native_registration.mmd | 39 - docs/diagrams/06_native_registration.puml | 48 +- docs/diagrams/07_native_get_consensus.mmd | 60 - docs/diagrams/07_native_get_consensus.puml | 102 +- docs/diagrams/08_nats_create_resource.mmd | 49 - ...e.puml => 08_nats_create_update_peer.puml} | 28 +- docs/diagrams/09_nats_propagation.mmd | 66 -- docs/diagrams/09_nats_propagation.puml | 49 +- docs/diagrams/10_pubsub_search.mmd | 52 - docs/diagrams/10_pubsub_search.puml | 40 +- docs/diagrams/11_stream_search.mmd | 52 - docs/diagrams/11_stream_search.puml | 18 +- docs/diagrams/12_partner_heartbeat.mmd | 58 - docs/diagrams/13_planner_flow.mmd | 49 - docs/diagrams/14_native_offload_gc.mmd | 59 - docs/diagrams/15_archi_config_nominale.puml | 49 + docs/diagrams/16_archi_config_seed.puml | 38 + .../17_startup_consensus_phase1_phase2.puml | 63 + .../18_startup_seed_discovers_native.puml | 51 + docs/diagrams/19_failure_indexer_crash.puml | 55 + .../20_failure_indexers_native_falback.puml | 51 + docs/diagrams/21_failure_native_one_down.puml | 46 + docs/diagrams/22_failure_both_natives.puml | 60 + .../23_failure_native_plus_indexer.puml | 63 + .../24_failure_retry_lost_native.puml | 45 + docs/diagrams/25_failure_node_gc.puml | 42 + docs/diagrams/README.md | 80 +- main.go | 2 - 47 files changed, 2704 insertions(+), 1034 deletions(-) create mode 100644 docs/DECENTRALIZED_SYSTEMS_COMPARISON.txt create mode 100644 docs/FUTURE_DHT_ARCHITECTURE.txt delete mode 100644 docs/diagrams/01_node_init.mmd delete mode 100644 docs/diagrams/02_node_claim.mmd delete mode 100644 docs/diagrams/03_indexer_heartbeat.mmd delete mode 100644 docs/diagrams/04_indexer_publish.mmd delete mode 100644 docs/diagrams/05_indexer_get.mmd delete mode 100644 docs/diagrams/06_native_registration.mmd delete mode 100644 docs/diagrams/07_native_get_consensus.mmd delete mode 100644 docs/diagrams/08_nats_create_resource.mmd rename docs/diagrams/{08_nats_create_resource.puml => 08_nats_create_update_peer.puml} (59%) delete mode 100644 docs/diagrams/09_nats_propagation.mmd delete mode 100644 docs/diagrams/10_pubsub_search.mmd delete mode 100644 docs/diagrams/11_stream_search.mmd delete mode 100644 docs/diagrams/12_partner_heartbeat.mmd delete mode 100644 docs/diagrams/13_planner_flow.mmd delete mode 100644 docs/diagrams/14_native_offload_gc.mmd create mode 100644 docs/diagrams/15_archi_config_nominale.puml create mode 100644 docs/diagrams/16_archi_config_seed.puml create mode 100644 docs/diagrams/17_startup_consensus_phase1_phase2.puml create mode 100644 docs/diagrams/18_startup_seed_discovers_native.puml create mode 100644 docs/diagrams/19_failure_indexer_crash.puml create mode 100644 docs/diagrams/20_failure_indexers_native_falback.puml create mode 100644 docs/diagrams/21_failure_native_one_down.puml create mode 100644 docs/diagrams/22_failure_both_natives.puml create mode 100644 docs/diagrams/23_failure_native_plus_indexer.puml create mode 100644 docs/diagrams/24_failure_retry_lost_native.puml create mode 100644 docs/diagrams/25_failure_node_gc.puml diff --git a/daemons/node/common/common_pubsub.go b/daemons/node/common/common_pubsub.go index 15faba6..4468e56 100644 --- a/daemons/node/common/common_pubsub.go +++ b/daemons/node/common/common_pubsub.go @@ -166,8 +166,6 @@ func SubscribeEvents[T interface{}](s *LongLivedPubSubService, } func waitResults[T interface{}](s *LongLivedPubSubService, ctx context.Context, sub *pubsub.Subscription, proto string, timeout int, f func(context.Context, T, string)) { - fmt.Println("waitResults", proto) - defer ctx.Done() for { s.PubsubMu.Lock() // check safely if cache is actually notified subscribed to topic diff --git a/daemons/node/common/common_stream.go b/daemons/node/common/common_stream.go index cf2944c..ffd4dea 100644 --- a/daemons/node/common/common_stream.go +++ b/daemons/node/common/common_stream.go @@ -35,6 +35,10 @@ type LongLivedStreamRecordedService[T interface{}] struct { AfterDelete func(pid pp.ID, name string, did string) } +func (ix *LongLivedStreamRecordedService[T]) MaxNodesConn() int { + return ix.maxNodesConn +} + func NewStreamRecordedService[T interface{}](h host.Host, maxNodesConn int) *LongLivedStreamRecordedService[T] { service := &LongLivedStreamRecordedService[T]{ LongLivedPubSubService: NewLongLivedPubSubService(h), @@ -160,25 +164,26 @@ func (ix *LongLivedStreamRecordedService[T]) HandleHeartbeat(s network.Stream) { // if record already seen update last seen if rec, ok := streams[*pid]; ok { rec.DID = hb.DID - if rec.HeartbeatStream == nil { - rec.HeartbeatStream = hb.Stream - } + // Preserve the existing UptimeTracker so TotalOnline accumulates correctly. + // hb.Stream is a fresh Stream with no UptimeTracker; carry the old one over. + oldTracker := rec.GetUptimeTracker() rec.HeartbeatStream = hb.Stream - if rec.HeartbeatStream.UptimeTracker == nil { - rec.HeartbeatStream.UptimeTracker = &UptimeTracker{ - FirstSeen: time.Now().UTC(), - LastSeen: time.Now().UTC(), - } + if oldTracker != nil { + rec.HeartbeatStream.UptimeTracker = oldTracker + } else { + rec.HeartbeatStream.UptimeTracker = &UptimeTracker{FirstSeen: time.Now().UTC()} } + rec.HeartbeatStream.UptimeTracker.RecordHeartbeat() + rec.LastScore = hb.Score logger.Info().Msg("A new node is updated : " + pid.String()) } else { - hb.Stream.UptimeTracker = &UptimeTracker{ - FirstSeen: time.Now().UTC(), - LastSeen: time.Now().UTC(), - } + tracker := &UptimeTracker{FirstSeen: time.Now().UTC()} + tracker.RecordHeartbeat() + hb.Stream.UptimeTracker = tracker streams[*pid] = &StreamRecord[T]{ DID: hb.DID, HeartbeatStream: hb.Stream, + LastScore: hb.Score, } logger.Info().Msg("A new node is subscribed : " + pid.String()) } @@ -215,30 +220,33 @@ func CheckHeartbeat(h host.Host, s network.Stream, dec *json.Decoder, streams ma if err := dec.Decode(&hb); err != nil { return nil, nil, err } - _, bpms, _ := getBandwidthChallengeRate(h, s.Conn().RemotePeer(), MinPayloadChallenge+int(rand.Float64()*(MaxPayloadChallenge-MinPayloadChallenge))) + _, bpms, latencyScore, _ := getBandwidthChallengeRate(h, s.Conn().RemotePeer(), MinPayloadChallenge+int(rand.Float64()*(MaxPayloadChallenge-MinPayloadChallenge))) { pid, err := pp.Decode(hb.PeerID) if err != nil { return nil, nil, err } - upTime := float64(0) - isFirstHeartbeat := true + uptimeRatio := float64(0) + age := time.Duration(0) lock.Lock() if rec, ok := streams[pid]; ok && rec.GetUptimeTracker() != nil { - upTime = rec.GetUptimeTracker().Uptime().Hours() / float64(time.Since(TimeWatcher).Hours()) - isFirstHeartbeat = false + uptimeRatio = rec.GetUptimeTracker().UptimeRatio() + age = rec.GetUptimeTracker().Uptime() } lock.Unlock() - diversity := getDiversityRate(h, hb.IndexersBinded) - hb.ComputeIndexerScore(upTime, bpms, diversity) - // First heartbeat: uptime is always 0 so the score ceiling is 60, below the - // steady-state threshold of 75. Use a lower admission threshold so new peers - // can enter and start accumulating uptime. Subsequent heartbeats must meet - // the full threshold once uptime is tracked. - minScore := float64(40) - if isFirstHeartbeat { - minScore = 40 + // E: measure the indexer's own subnet diversity, not the node's view. + diversity := getOwnDiversityRate(h) + // fillRate: fraction of indexer capacity used — higher = more peers trust this indexer. + fillRate := 0.0 + if maxNodes > 0 { + fillRate = float64(len(h.Network().Peers())) / float64(maxNodes) + if fillRate > 1 { + fillRate = 1 + } } + hb.ComputeIndexerScore(uptimeRatio, bpms, diversity, latencyScore, fillRate) + // B: dynamic minScore — starts at 20% for brand-new peers, ramps to 80% at 24h. + minScore := dynamicMinScore(age) if hb.Score < minScore { return nil, nil, errors.New("not enough trusting value") } @@ -247,7 +255,7 @@ func CheckHeartbeat(h host.Host, s network.Stream, dec *json.Decoder, streams ma DID: hb.DID, Stream: s, Expiry: time.Now().UTC().Add(2 * time.Minute), - } // here is the long-lived bidirectionnal heart bit. + } // here is the long-lived bidirectional heartbeat. return &pid, &hb, err } } @@ -268,7 +276,40 @@ func getDiversityRate(h host.Host, peers []string) float64 { if len(diverse) == 0 || len(peers) == 0 { return 1 } - return float64(len(diverse) / len(peers)) + return float64(len(diverse)) / float64(len(peers)) +} + +// getOwnDiversityRate measures subnet /24 diversity of the indexer's own connected peers. +// This evaluates the indexer's network position rather than the connecting node's topology. +func getOwnDiversityRate(h host.Host) float64 { + diverse := map[string]struct{}{} + total := 0 + for _, pid := range h.Network().Peers() { + for _, maddr := range h.Peerstore().Addrs(pid) { + total++ + ip, err := ExtractIP(maddr.String()) + if err != nil { + continue + } + diverse[ip.Mask(net.CIDRMask(24, 32)).String()] = struct{}{} + } + } + if total == 0 { + return 1 + } + return float64(len(diverse)) / float64(total) +} + +// dynamicMinScore returns the minimum acceptable score for a peer, starting +// permissive (20%) for brand-new peers and hardening linearly to 80% over 24h. +// This prevents ejecting newcomers in fresh networks while filtering parasites. +func dynamicMinScore(age time.Duration) float64 { + hours := age.Hours() + score := 20.0 + 60.0*(hours/24.0) + if score > 80.0 { + score = 80.0 + } + return score } func checkPeers(h host.Host, peers []string) ([]string, []string) { @@ -295,53 +336,95 @@ const MaxPayloadChallenge = 2048 const BaseRoundTrip = 400 * time.Millisecond // getBandwidthChallengeRate opens a dedicated ProtocolBandwidthProbe stream to -// remotePeer, sends a random payload, reads the echo, and computes throughput. +// remotePeer, sends a random payload, reads the echo, and computes throughput +// and a latency score. Returns (ok, bpms, latencyScore, error). +// latencyScore is 1.0 when RTT is very fast and 0.0 when at or beyond maxRoundTrip. // Using a separate stream avoids mixing binary data on the JSON heartbeat stream // and ensures the echo handler is actually running on the remote side. -func getBandwidthChallengeRate(h host.Host, remotePeer pp.ID, payloadSize int) (bool, float64, error) { +func getBandwidthChallengeRate(h host.Host, remotePeer pp.ID, payloadSize int) (bool, float64, float64, error) { payload := make([]byte, payloadSize) if _, err := cr.Read(payload); err != nil { - return false, 0, err + return false, 0, 0, err } ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() s, err := h.NewStream(ctx, remotePeer, ProtocolBandwidthProbe) if err != nil { - return false, 0, err + return false, 0, 0, err } defer s.Reset() s.SetDeadline(time.Now().Add(10 * time.Second)) start := time.Now() if _, err = s.Write(payload); err != nil { - return false, 0, err + return false, 0, 0, err } s.CloseWrite() // Half-close the write side so the handler's io.Copy sees EOF and stops. // Read the echo. response := make([]byte, payloadSize) if _, err = io.ReadFull(s, response); err != nil { - return false, 0, err + return false, 0, 0, err } duration := time.Since(start) maxRoundTrip := BaseRoundTrip + (time.Duration(payloadSize) * (100 * time.Millisecond)) mbps := float64(payloadSize*8) / duration.Seconds() / 1e6 - if duration > maxRoundTrip || mbps < 5.0 { - return false, float64(mbps / MaxExpectedMbps), nil + + // latencyScore: 1.0 = instant, 0.0 = at maxRoundTrip or beyond. + latencyScore := 1.0 - float64(duration)/float64(maxRoundTrip) + if latencyScore < 0 { + latencyScore = 0 } - return true, float64(mbps / MaxExpectedMbps), nil + if latencyScore > 1 { + latencyScore = 1 + } + + if duration > maxRoundTrip || mbps < 5.0 { + return false, float64(mbps / MaxExpectedMbps), latencyScore, nil + } + return true, float64(mbps / MaxExpectedMbps), latencyScore, nil } type UptimeTracker struct { - FirstSeen time.Time - LastSeen time.Time + FirstSeen time.Time + LastSeen time.Time + TotalOnline time.Duration +} + +// RecordHeartbeat accumulates online time gap-aware: only counts the interval if +// the gap since the last heartbeat is within 2× the recommended interval (i.e. no +// extended outage). Call this each time a heartbeat is successfully processed. +func (u *UptimeTracker) RecordHeartbeat() { + now := time.Now().UTC() + if !u.LastSeen.IsZero() { + gap := now.Sub(u.LastSeen) + if gap <= 2*RecommendedHeartbeatInterval { + u.TotalOnline += gap + } + } + u.LastSeen = now } func (u *UptimeTracker) Uptime() time.Duration { return time.Since(u.FirstSeen) } +// UptimeRatio returns the fraction of tracked lifetime during which the peer was +// continuously online (gap ≤ 2×RecommendedHeartbeatInterval). Returns 0 before +// the first heartbeat interval has elapsed. +func (u *UptimeTracker) UptimeRatio() float64 { + total := time.Since(u.FirstSeen) + if total <= 0 { + return 0 + } + ratio := float64(u.TotalOnline) / float64(total) + if ratio > 1 { + ratio = 1 + } + return ratio +} + func (u *UptimeTracker) IsEligible(min time.Duration) bool { return u.Uptime() >= min } @@ -350,6 +433,7 @@ type StreamRecord[T interface{}] struct { DID string HeartbeatStream *Stream Record T + LastScore float64 } func (s *StreamRecord[T]) GetUptimeTracker() *UptimeTracker { @@ -426,7 +510,24 @@ const ( var TimeWatcher time.Time +// IndexerRecord holds admission metadata for an indexer in the pool. +// AdmittedAt is zero for seed entries (IndexerAddresses) never validated by a native. +// It is set to the admission time when a native confirms the indexer via consensus. +type IndexerRecord struct { + AdmittedAt time.Time +} + +// IsStableVoter returns true when this indexer has been admitted by a native +// long enough ago to participate as a voter in Phase 2 liveness voting. +func (r *IndexerRecord) IsStableVoter() bool { + return !r.AdmittedAt.IsZero() && time.Since(r.AdmittedAt) >= MinStableAge +} + var StaticIndexers map[string]*pp.AddrInfo = map[string]*pp.AddrInfo{} + +// StaticIndexerMeta mirrors StaticIndexers with admission metadata. +// Both maps are always updated together under StreamMuIndexes. +var StaticIndexerMeta map[string]*IndexerRecord = map[string]*IndexerRecord{} var StreamMuIndexes sync.RWMutex var StreamIndexers ProtocolStream = ProtocolStream{} @@ -462,28 +563,64 @@ func ConnectToIndexers(h host.Host, minIndexer int, maxIndexer int, myPID pp.ID, return nil } + // No native configured: bootstrap from IndexerAddresses seed set. addresses := strings.Split(conf.GetConfig().IndexerAddresses, ",") - if len(addresses) > maxIndexer { addresses = addresses[0:maxIndexer] } StreamMuIndexes.Lock() for _, indexerAddr := range addresses { + indexerAddr = strings.TrimSpace(indexerAddr) + if indexerAddr == "" { + continue + } ad, err := pp.AddrInfoFromString(indexerAddr) if err != nil { logger.Err(err) continue } + // AdmittedAt zero = seed, not yet validated by a native. StaticIndexers[indexerAddr] = ad + StaticIndexerMeta[indexerAddr] = &IndexerRecord{} } indexerCount := len(StaticIndexers) StreamMuIndexes.Unlock() - SendHeartbeat(context.Background(), ProtocolHeartbeat, conf.GetConfig().Name, h, StreamIndexers, StaticIndexers, &StreamMuIndexes, 20*time.Second, recordFn...) // your indexer is just like a node for the next indexer. if indexerCount < minIndexer { return errors.New("you run a node without indexers... your gonna be isolated.") } + + // Start long-lived heartbeat to seed indexers. The single goroutine follows + // all subsequent StaticIndexers changes (including after native discovery). + SendHeartbeat(context.Background(), ProtocolHeartbeat, conf.GetConfig().Name, + h, StreamIndexers, StaticIndexers, &StreamMuIndexes, 20*time.Second, recordFn...) + + // Async: ask seed indexers whether they know a native — same logic as + // replenishNativesFromPeers. Runs after a short delay to let h.Connect warm up. + go func() { + time.Sleep(2 * time.Second) + logger.Info().Msg("[startup] no native configured — asking seed indexers for native addresses") + newAddr := fetchNativeFromIndexers(h, nil) + if newAddr == "" { + logger.Info().Msg("[startup] no native found from seed indexers — pure indexer mode") + return + } + ad, err := pp.AddrInfoFromString(newAddr) + if err != nil { + return + } + logger.Info().Str("addr", newAddr).Msg("[startup] native discovered via seed indexers — bootstrapping") + StreamNativeMu.Lock() + StaticNatives[newAddr] = ad + StreamNativeMu.Unlock() + // Full native bootstrap: fetch pool, run consensus, replace StaticIndexers + // with properly admitted records (AdmittedAt set). + if err := ConnectToNatives(h, minIndexer, maxIndexer, myPID); err != nil { + logger.Warn().Err(err).Msg("[startup] native bootstrap failed after discovery") + } + }() + return nil } @@ -536,10 +673,19 @@ type Heartbeat struct { Record json.RawMessage `json:"record,omitempty"` } -func (hb *Heartbeat) ComputeIndexerScore(uptimeHours float64, bpms float64, diversity float64) { - hb.Score = ((0.3 * uptimeHours) + - (0.3 * bpms) + - (0.4 * diversity)) * 100 +// ComputeIndexerScore computes a composite quality score [0, 100] for the connecting peer. +// - uptimeRatio: fraction of tracked lifetime online (gap-aware) — peer reliability +// - bpms: bandwidth normalized to MaxExpectedMbps — link capacity +// - diversity: indexer's own /24 subnet diversity — network topology quality +// - latencyScore: 1 - RTT/maxRoundTrip — link responsiveness +// - fillRate: fraction of indexer slots used (0=empty, 1=full) — collective trust signal: +// a fuller indexer has been chosen and retained by many peers, which is evidence of quality. +func (hb *Heartbeat) ComputeIndexerScore(uptimeRatio float64, bpms float64, diversity float64, latencyScore float64, fillRate float64) { + hb.Score = ((0.20 * uptimeRatio) + + (0.20 * bpms) + + (0.20 * diversity) + + (0.15 * latencyScore) + + (0.25 * fillRate)) * 100 } type HeartbeatInfo []struct { @@ -616,6 +762,9 @@ func SendHeartbeat(ctx context.Context, proto protocol.ID, name string, h host.H for _, ix := range snapshot { wasConnected := h.Network().Connectedness(ix.ID) == network.Connected + StreamNativeMu.RLock() + hasNative := len(StaticNatives) > 0 + StreamNativeMu.RUnlock() if err := sendHeartbeat(ctx, h, proto, ix, hb, ps, interval*time.Second); err != nil { // Step 3: heartbeat failed — remove from pool and trigger replenish. logger.Info().Str("peer", ix.ID.String()).Str("proto", string(proto)).Msg("[native] step 3 — heartbeat failed, removing peer from pool") @@ -639,6 +788,9 @@ func SendHeartbeat(ctx context.Context, proto protocol.ID, name string, h host.H if ad.ID == ix.ID { lostAddr = addr delete(peers, addr) + if isIndexerHB { + delete(StaticIndexerMeta, addr) + } break } } @@ -650,7 +802,8 @@ func SendHeartbeat(ctx context.Context, proto protocol.ID, name string, h host.H logger.Info().Int("remaining", remaining).Int("min", conf.GetConfig().MinIndexer).Int("need", need).Msg("[native] step 3 — pool state after removal") // Step 4: ask the native for the missing indexer count. - if isIndexerHB && conf.GetConfig().NativeIndexerAddresses != "" { + // hasNative computed above (used in both err and success branches). + if isIndexerHB && hasNative { if need < 1 { need = 1 } @@ -663,7 +816,7 @@ func SendHeartbeat(ctx context.Context, proto protocol.ID, name string, h host.H // from StaticIndexers immediately without waiting for the indexer HB tick. if isNativeHB { logger.Info().Str("addr", lostAddr).Msg("[native] step 3 — native heartbeat failed, triggering native replenish") - if lostAddr != "" && conf.GetConfig().NativeIndexerAddresses != "" { + if lostAddr != "" && hasNative { StreamMuIndexes.Lock() if _, wasIndexer := StaticIndexers[lostAddr]; wasIndexer { delete(StaticIndexers, lostAddr) @@ -695,7 +848,7 @@ func SendHeartbeat(ctx context.Context, proto protocol.ID, name string, h host.H // blank state (responsiblePeers empty). Evict it from StaticIndexers and // re-request an assignment so the native re-tracks us properly and // runOffloadLoop can eventually migrate us to real indexers. - if !wasConnected && isIndexerHB && conf.GetConfig().NativeIndexerAddresses != "" { + if !wasConnected && isIndexerHB && hasNative { StreamNativeMu.RLock() isNativeIndexer := false for _, ad := range StaticNatives { diff --git a/daemons/node/common/native_stream.go b/daemons/node/common/native_stream.go index 8ac0d78..1e4539f 100644 --- a/daemons/node/common/native_stream.go +++ b/daemons/node/common/native_stream.go @@ -35,8 +35,27 @@ const ( consensusQueryTimeout = 3 * time.Second // consensusCollectTimeout is the total wait for all native responses. consensusCollectTimeout = 4 * time.Second + + // ProtocolIndexerConsensus is the Phase 2 liveness-voting protocol. + // Each stable indexer is asked which candidates it considers reachable. + ProtocolIndexerConsensus = "/opencloud/indexer/consensus/1.0" + + // MinStableAge is the minimum time since native admission before an indexer + // may participate as a voter in Phase 2 liveness voting. + MinStableAge = 2 * time.Minute ) +// IndexerConsensusRequest is sent to stable indexers during Phase 2 liveness voting. +// Each voter replies with which candidates from the list it can currently reach. +type IndexerConsensusRequest struct { + Candidates []string `json:"candidates"` +} + +// IndexerConsensusResponse is the reply from a Phase 2 voter. +type IndexerConsensusResponse struct { + Alive []string `json:"alive"` +} + // ConsensusRequest is sent by a node/indexer to a native to validate a candidate // indexer list. The native replies with what it trusts and what it suggests instead. type ConsensusRequest struct { @@ -56,11 +75,12 @@ type ConsensusResponse struct { // Timestamp + PubKey + Signature allow the native and DHT to verify that the // registration was produced by the peer that owns the declared PeerID. type IndexerRegistration struct { - PeerID string `json:"peer_id,omitempty"` - Addr string `json:"addr"` - Timestamp int64 `json:"ts,omitempty"` // Unix nanoseconds (anti-replay) - PubKey []byte `json:"pub_key,omitempty"` // marshaled libp2p public key - Signature []byte `json:"sig,omitempty"` // Sign(signaturePayload()) + PeerID string `json:"peer_id,omitempty"` + Addr string `json:"addr"` + Timestamp int64 `json:"ts,omitempty"` // Unix nanoseconds (anti-replay) + PubKey []byte `json:"pub_key,omitempty"` // marshaled libp2p public key + Signature []byte `json:"sig,omitempty"` // Sign(signaturePayload()) + FillRate float64 `json:"fill_rate,omitempty"` // connected_nodes / max_nodes (0=empty, 1=full) } // SignaturePayload returns the canonical byte slice that is signed/verified. @@ -106,9 +126,12 @@ type GetIndexersRequest struct { } // GetIndexersResponse is returned by the native with live indexer multiaddrs. +// FillRates maps each indexer address to its last reported fill rate (0=empty, 1=full). +// Nodes use fill rates to prefer indexers with available capacity. type GetIndexersResponse struct { - Indexers []string `json:"indexers"` - IsSelfFallback bool `json:"is_self_fallback,omitempty"` + Indexers []string `json:"indexers"` + IsSelfFallback bool `json:"is_self_fallback,omitempty"` + FillRates map[string]float64 `json:"fill_rates,omitempty"` } var StaticNatives = map[string]*pp.AddrInfo{} @@ -177,8 +200,8 @@ func ConnectToNatives(h host.Host, minIndexer int, maxIndexer int, myPID pp.ID) logger.Info().Int("candidates", len(candidates)).Bool("fallback", isFallback).Msg("[native] step 1 — pool received") // Step 2: populate StaticIndexers — consensus for real indexers, direct for fallback. - pool := resolvePool(h, candidates, isFallback, maxIndexer) - replaceStaticIndexers(pool) + pool, admittedAt := resolvePool(h, candidates, isFallback, maxIndexer) + replaceStaticIndexers(pool, admittedAt) StreamMuIndexes.RLock() indexerCount := len(StaticIndexers) @@ -216,7 +239,7 @@ func replenishIndexersFromNative(h host.Host, need int) { } logger.Info().Int("candidates", len(candidates)).Bool("fallback", isFallback).Msg("[native] step 4 — candidates received") - pool := resolvePool(h, candidates, isFallback, need) + pool, admittedAt := resolvePool(h, candidates, isFallback, need) if len(pool) == 0 { logger.Warn().Msg("[native] step 4 — consensus yielded no confirmed indexers") return @@ -226,9 +249,11 @@ func replenishIndexersFromNative(h host.Host, need int) { StreamMuIndexes.Lock() for addr, ad := range pool { StaticIndexers[addr] = ad + if StaticIndexerMeta[addr] == nil { + StaticIndexerMeta[addr] = &IndexerRecord{AdmittedAt: admittedAt} + } } total := len(StaticIndexers) - StreamMuIndexes.Unlock() logger.Info().Int("added", len(pool)).Int("total", total).Msg("[native] step 4 — pool replenished") @@ -335,9 +360,9 @@ collect: } // resolvePool converts a candidate list to a validated addr→AddrInfo map. -// When isFallback is true the native itself is the indexer — no consensus needed. -// When isFallback is false, consensus is run before accepting the candidates. -func resolvePool(h host.Host, candidates []string, isFallback bool, maxIndexer int) map[string]*pp.AddrInfo { +// When isFallback is true the native itself is the indexer — no Phase 1 consensus needed. +// Returns the pool and the admission timestamp (zero for fallback/seed entries). +func resolvePool(h host.Host, candidates []string, isFallback bool, maxIndexer int) (map[string]*pp.AddrInfo, time.Time) { logger := oclib.GetLogger() if isFallback { logger.Info().Strs("addrs", candidates).Msg("[native] resolve — fallback mode, skipping consensus") @@ -349,9 +374,10 @@ func resolvePool(h host.Host, candidates []string, isFallback bool, maxIndexer i } pool[addr] = ad } - return pool + return pool, time.Time{} } + // Phase 1 — native admission. // Round 1. logger.Info().Int("candidates", len(candidates)).Msg("[native] resolve — consensus round 1") confirmed, suggestions := clientSideConsensus(h, candidates) @@ -372,6 +398,7 @@ func resolvePool(h host.Host, candidates []string, isFallback bool, maxIndexer i logger.Info().Int("confirmed", len(confirmed)).Msg("[native] resolve — consensus round 2 done") } + admittedAt := time.Now().UTC() pool := make(map[string]*pp.AddrInfo, len(confirmed)) for _, addr := range confirmed { ad, err := pp.AddrInfoFromString(addr) @@ -380,18 +407,130 @@ func resolvePool(h host.Host, candidates []string, isFallback bool, maxIndexer i } pool[addr] = ad } - logger.Info().Int("pool_size", len(pool)).Msg("[native] resolve — pool ready") - return pool + + // Phase 2 — indexer liveness vote. + logger.Info().Int("pool_size", len(pool)).Msg("[native] resolve — Phase 1 done, running Phase 2 liveness vote") + pool = indexerLivenessVote(h, pool) + logger.Info().Int("pool_size", len(pool)).Msg("[native] resolve — Phase 2 done, pool ready") + return pool, admittedAt +} + +// indexerLivenessVote runs Phase 2 of the hybrid consensus: it queries every +// stable indexer in StaticIndexers (AdmittedAt non-zero, age >= MinStableAge) +// for their view of the candidate list and returns only the candidates confirmed +// by quorum. When no stable voter exists the full admitted set is returned +// unchanged — this is correct on first boot before any indexer is old enough. +func indexerLivenessVote(h host.Host, admitted map[string]*pp.AddrInfo) map[string]*pp.AddrInfo { + logger := oclib.GetLogger() + + StreamMuIndexes.RLock() + voters := make([]*pp.AddrInfo, 0, len(StaticIndexers)) + for addr, ad := range StaticIndexers { + if meta, ok := StaticIndexerMeta[addr]; ok && meta.IsStableVoter() { + voters = append(voters, ad) + } + } + StreamMuIndexes.RUnlock() + + if len(voters) == 0 { + logger.Info().Msg("[phase2] no stable voters yet — trusting Phase 1 result") + return admitted + } + + candidates := make([]string, 0, len(admitted)) + for addr := range admitted { + candidates = append(candidates, addr) + } + + type result struct { + alive map[string]struct{} + ok bool + } + ch := make(chan result, len(voters)) + + for _, voter := range voters { + go func(v *pp.AddrInfo) { + ctx, cancel := context.WithTimeout(context.Background(), consensusQueryTimeout) + defer cancel() + if err := h.Connect(ctx, *v); err != nil { + ch <- result{} + return + } + s, err := h.NewStream(ctx, v.ID, ProtocolIndexerConsensus) + if err != nil { + ch <- result{} + return + } + s.SetDeadline(time.Now().Add(consensusQueryTimeout)) + defer s.Close() + if err := json.NewEncoder(s).Encode(IndexerConsensusRequest{Candidates: candidates}); err != nil { + ch <- result{} + return + } + var resp IndexerConsensusResponse + if err := json.NewDecoder(s).Decode(&resp); err != nil { + ch <- result{} + return + } + alive := make(map[string]struct{}, len(resp.Alive)) + for _, a := range resp.Alive { + alive[a] = struct{}{} + } + ch <- result{alive: alive, ok: true} + }(voter) + } + + timer := time.NewTimer(consensusCollectTimeout) + defer timer.Stop() + + aliveCounts := map[string]int{} + total, collected := 0, 0 +collect: + for collected < len(voters) { + select { + case r := <-ch: + collected++ + if !r.ok { + continue + } + total++ + for addr := range r.alive { + aliveCounts[addr]++ + } + case <-timer.C: + break collect + } + } + + if total == 0 { + logger.Info().Msg("[phase2] no voter responded — trusting Phase 1 result") + return admitted + } + + quorum := conf.GetConfig().ConsensusQuorum + if quorum <= 0 { + quorum = 0.5 + } + confirmed := make(map[string]*pp.AddrInfo, len(admitted)) + for addr, ad := range admitted { + if float64(aliveCounts[addr]) > float64(total)*quorum { + confirmed[addr] = ad + } + } + logger.Info().Int("admitted", len(admitted)).Int("confirmed", len(confirmed)).Int("voters", total).Msg("[phase2] liveness vote complete") + return confirmed } // replaceStaticIndexers atomically replaces the active indexer pool. -// Peers no longer in next have their heartbeat streams closed so the SendHeartbeat -// goroutine stops sending to them on the next tick. -func replaceStaticIndexers(next map[string]*pp.AddrInfo) { +// admittedAt is the time of native admission (zero for fallback/seed entries). +func replaceStaticIndexers(next map[string]*pp.AddrInfo, admittedAt time.Time) { StreamMuIndexes.Lock() defer StreamMuIndexes.Unlock() for addr, ad := range next { StaticIndexers[addr] = ad + if StaticIndexerMeta[addr] == nil { + StaticIndexerMeta[addr] = &IndexerRecord{AdmittedAt: admittedAt} + } } } @@ -508,8 +647,10 @@ collect: } // RegisterWithNative sends a one-shot registration to each configured native indexer. +// fillRateFn, when non-nil, is called to obtain the current fill rate (0=empty, 1=full) +// which the native uses to route new nodes toward less-loaded indexers. // Should be called periodically every RecommendedHeartbeatInterval. -func RegisterWithNative(h host.Host, nativeAddressesStr string) { +func RegisterWithNative(h host.Host, nativeAddressesStr string, fillRateFn func() float64) { logger := oclib.GetLogger() myAddr := "" if !strings.Contains(h.Addrs()[len(h.Addrs())-1].String(), "127.0.0.1") { @@ -524,6 +665,9 @@ func RegisterWithNative(h host.Host, nativeAddressesStr string) { Addr: myAddr, Timestamp: time.Now().UnixNano(), } + if fillRateFn != nil { + reg.FillRate = fillRateFn() + } reg.Sign(h) for _, addr := range strings.Split(nativeAddressesStr, ",") { addr = strings.TrimSpace(addr) @@ -619,7 +763,10 @@ func EnsureNativePeers(h host.Host) { }) } -func StartNativeRegistration(h host.Host, nativeAddressesStr string) { +// StartNativeRegistration starts a goroutine that periodically registers this +// indexer with all configured native indexers (every RecommendedHeartbeatInterval). +// fillRateFn is called on each registration tick to report current capacity usage. +func StartNativeRegistration(h host.Host, nativeAddressesStr string, fillRateFn func() float64) { go func() { // Poll until a routable (non-loopback) address is available before the first // registration attempt. libp2p may not have discovered external addresses yet @@ -636,11 +783,11 @@ func StartNativeRegistration(h host.Host, nativeAddressesStr string) { } time.Sleep(5 * time.Second) } - RegisterWithNative(h, nativeAddressesStr) + RegisterWithNative(h, nativeAddressesStr, fillRateFn) t := time.NewTicker(RecommendedHeartbeatInterval) defer t.Stop() for range t.C { - RegisterWithNative(h, nativeAddressesStr) + RegisterWithNative(h, nativeAddressesStr, fillRateFn) } }() } @@ -917,7 +1064,7 @@ func retryLostNative(ctx context.Context, h host.Host, addr string, nativeProto NudgeNativeHeartbeat() replenishIndexersIfNeeded(h) if nativeProto == ProtocolNativeGetIndexers { - StartNativeRegistration(h, addr) // register back + StartNativeRegistration(h, addr, nil) // register back (fill rate unknown in this context) } return } diff --git a/daemons/node/indexer/handler.go b/daemons/node/indexer/handler.go index ec178d2..125eae1 100644 --- a/daemons/node/indexer/handler.go +++ b/daemons/node/indexer/handler.go @@ -178,6 +178,43 @@ func (ix *IndexerService) initNodeHandler() { ix.Host.SetStreamHandler(common.ProtocolPublish, ix.handleNodePublish) ix.Host.SetStreamHandler(common.ProtocolGet, ix.handleNodeGet) ix.Host.SetStreamHandler(common.ProtocolIndexerGetNatives, ix.handleGetNatives) + ix.Host.SetStreamHandler(common.ProtocolIndexerConsensus, ix.handleIndexerConsensus) +} + +// handleIndexerConsensus implements Phase 2 liveness voting (ProtocolIndexerConsensus). +// The caller sends a list of candidate multiaddrs; this indexer replies with the +// subset it considers currently alive (recent heartbeat in StreamRecords). +func (ix *IndexerService) handleIndexerConsensus(stream network.Stream) { + defer stream.Reset() + + var req common.IndexerConsensusRequest + if err := json.NewDecoder(stream).Decode(&req); err != nil { + return + } + + ix.StreamMU.RLock() + streams := ix.StreamRecords[common.ProtocolHeartbeat] + ix.StreamMU.RUnlock() + + alive := make([]string, 0, len(req.Candidates)) + for _, addr := range req.Candidates { + ad, err := peer.AddrInfoFromString(addr) + if err != nil { + continue + } + ix.StreamMU.RLock() + rec, ok := streams[ad.ID] + ix.StreamMU.RUnlock() + if !ok || rec.HeartbeatStream == nil || rec.HeartbeatStream.UptimeTracker == nil { + continue + } + // D: consider alive only if recent heartbeat AND score above minimum quality bar. + if time.Since(rec.HeartbeatStream.UptimeTracker.LastSeen) <= 2*common.RecommendedHeartbeatInterval && + rec.LastScore >= 30.0 { + alive = append(alive, addr) + } + } + json.NewEncoder(stream).Encode(common.IndexerConsensusResponse{Alive: alive}) } func (ix *IndexerService) handleNodePublish(s network.Stream) { diff --git a/daemons/node/indexer/native.go b/daemons/node/indexer/native.go index 2d11e8a..4677e41 100644 --- a/daemons/node/indexer/native.go +++ b/daemons/node/indexer/native.go @@ -40,6 +40,7 @@ const ( // liveIndexerEntry tracks a registered indexer in the native's in-memory cache and DHT. // PubKey and Signature are forwarded from the IndexerRegistration so the DHT validator // can verify that the entry was produced by the peer owning the declared PeerID. +// FillRate is the fraction of capacity used (0=empty, 1=full) at last registration. type liveIndexerEntry struct { PeerID string `json:"peer_id"` Addr string `json:"addr"` @@ -47,6 +48,7 @@ type liveIndexerEntry struct { RegTimestamp int64 `json:"reg_ts,omitempty"` // Timestamp from the original IndexerRegistration PubKey []byte `json:"pub_key,omitempty"` Signature []byte `json:"sig,omitempty"` + FillRate float64 `json:"fill_rate,omitempty"` } // NativeState holds runtime state specific to native indexer operation. @@ -265,6 +267,7 @@ func (ix *IndexerService) handleNativeSubscription(s network.Stream) { RegTimestamp: reg.Timestamp, PubKey: reg.PubKey, Signature: reg.Signature, + FillRate: reg.FillRate, } // Verify that the declared address is actually reachable before admitting @@ -428,11 +431,40 @@ func (ix *IndexerService) handleNativeGetIndexers(s network.Stream) { "native: fallback pool saturated, refusing self-delegation") } } else { - rand.Shuffle(len(reachable), func(i, j int) { reachable[i], reachable[j] = reachable[j], reachable[i] }) + // Sort by fill rate ascending so less-full indexers are preferred for routing. + ix.Native.liveIndexersMu.RLock() + fillRates := make(map[string]float64, len(reachable)) + for _, addr := range reachable { + ad, err := pp.AddrInfoFromString(addr) + if err != nil { + continue + } + for _, e := range ix.Native.liveIndexers { + if e.PeerID == ad.ID.String() { + fillRates[addr] = e.FillRate + break + } + } + } + ix.Native.liveIndexersMu.RUnlock() + + // Sort by routing weight descending: weight = fillRate × (1 − fillRate). + // This prefers indexers in the "trust sweet spot" — proven popular (fillRate > 0) + // but not saturated (fillRate < 1). Peak at fillRate ≈ 0.5. + routingWeight := func(addr string) float64 { + f := fillRates[addr] + return f * (1 - f) + } + for i := 1; i < len(reachable); i++ { + for j := i; j > 0 && routingWeight(reachable[j]) > routingWeight(reachable[j-1]); j-- { + reachable[j], reachable[j-1] = reachable[j-1], reachable[j] + } + } if req.Count > len(reachable) { req.Count = len(reachable) } resp.Indexers = reachable[:req.Count] + resp.FillRates = fillRates } if err := json.NewEncoder(s).Encode(resp); err != nil { diff --git a/daemons/node/indexer/service.go b/daemons/node/indexer/service.go index d238fd6..e2b0dbf 100644 --- a/daemons/node/indexer/service.go +++ b/daemons/node/indexer/service.go @@ -96,9 +96,24 @@ func NewIndexerService(h host.Host, ps *pubsub.PubSub, maxNode int, isNative boo ix.InitNative() } else { ix.initNodeHandler() - // Register with configured natives so this indexer appears in their cache + // Register with configured natives so this indexer appears in their cache. + // Pass a fill rate provider so the native can route new nodes to less-loaded indexers. if nativeAddrs := conf.GetConfig().NativeIndexerAddresses; nativeAddrs != "" { - common.StartNativeRegistration(ix.Host, nativeAddrs) + fillRateFn := func() float64 { + ix.StreamMU.RLock() + n := len(ix.StreamRecords[common.ProtocolHeartbeat]) + ix.StreamMU.RUnlock() + maxN := ix.MaxNodesConn() + if maxN <= 0 { + return 0 + } + rate := float64(n) / float64(maxN) + if rate > 1 { + rate = 1 + } + return rate + } + common.StartNativeRegistration(ix.Host, nativeAddrs, fillRateFn) } } return ix diff --git a/daemons/node/node.go b/daemons/node/node.go index 08930da..cc8207c 100644 --- a/daemons/node/node.go +++ b/daemons/node/node.go @@ -123,7 +123,6 @@ func InitNode(isNode bool, isIndexer bool, isNativeIndexer bool) (*Node, error) m := map[string]interface{}{} err := json.Unmarshal(evt.Payload, &m) if err != nil || evt.From == node.PeerID.String() { - fmt.Println(evt.From, node.PeerID.String(), err) return } if p, err := node.GetPeerRecord(ctx, evt.From, false); err == nil && len(p) > 0 && m["search"] != nil { diff --git a/docs/DECENTRALIZED_SYSTEMS_COMPARISON.txt b/docs/DECENTRALIZED_SYSTEMS_COMPARISON.txt new file mode 100644 index 0000000..677aa1d --- /dev/null +++ b/docs/DECENTRALIZED_SYSTEMS_COMPARISON.txt @@ -0,0 +1,1030 @@ +================================================================================ + OC-DISCOVERY : CORRÉLATION AVEC LES SYSTÈMES DÉCENTRALISÉS EXISTANTS + Patterns, similitudes, divergences et comparaison +================================================================================ + +Rédigé à partir de l'analyse de l'architecture oc-discovery. +Références académiques et systèmes industriels cités en §9. + +================================================================================ +1. INTRODUCTION ET PÉRIMÈTRE +================================================================================ + +oc-discovery est un service de découverte P2P hiérarchique à trois niveaux +(node → indexer → native indexer) construit sur libp2p, une DHT Kademlia à +espace de noms privé, GossipSub, et un mécanisme de scoring de confiance +multidimensionnel. Le réseau est isolé par une clé pré-partagée (PSK). + +Ce document met en regard la conception de oc-discovery avec : + + (A) Les systèmes de découverte P2P historiques et académiques + (B) Les systèmes décentralisés industriels contemporains + (C) Les patterns architecturaux identifiables dans la littérature + (D) Une analyse comparative synthétique + + +================================================================================ +2. SYSTÈMES COMPARÉS +================================================================================ + +2.1 Kademlia DHT [Maymounkov & Mazières, 2002] +----------------------------------------------- + +Description : Table de hachage distribuée à espace à k-buckets. Chaque nœud +maintient un routage O(log n). Toute requête GET/PUT se résout en O(log n) sauts. +La distance est la métrique XOR sur les PeerIDs (160 bits). + +Utilisation dans oc-discovery : oc-discovery embarque une instance Kademlia +(go-libp2p-kad-dht) avec le préfixe "oc" — namespace privé qui isole la DHT +du réseau IPFS public. Quatre espaces de noms (/node, /indexer, /name, /pid) +y sont écrits avec des validateurs cryptographiques customisés. + +Similitudes : + - Résolution en O(log n) sans serveur central. + - Réplication Kademlia standard (k-buckets) pour la tolérance aux pannes. + - Chaque entrée est auto-signée (Ed25519). + +Divergences : + - Kademlia pur est flat : tout nœud est pair. oc-discovery superpose une + hiérarchie fonctionnelle (native > indexer > node) sur la DHT. + - Kademlia n'a pas de TTL applicatif par nature : oc-discovery impose des + TTL stricts (90 s pour /indexer, 2 min pour /node) avec retry borné. + - Kademlia pur n'a pas de mécanisme d'admission : tout nœud peut écrire. + oc-discovery ajoute une validation cryptographique (Sign+Verify) avant + tout PutValue dans l'espace /indexer. + - La DHT de oc-discovery est un stockage secondaire (cache mémoire des natifs + en primaire), non le plan de découverte principal — rôle tenu par le mesh + natif + heartbeat long-lived. + +Source : Maymounkov, P. & Mazières, D. (2002). "Kademlia: A Peer-to-Peer +Information System Based on the XOR Metric." IPTPS 2002, Springer LNCS 2429. + +---- + +2.2 Chord [Stoica et al., 2001] +-------------------------------- + +Description : DHT en anneau. Chaque nœud a un successeur et une finger table +de O(log n) entrées. Résolution en O(log n). Gestion des jonctions/départs via +le protocole de stabilisation. + +Similitudes : + - Résolution décentralisée sans serveur central. + - Tolérance aux pannes par réplication de successeurs. + +Divergences : + - Chord suppose un réseau ouvert et homogène. oc-discovery est fermé (PSK) + et hétérogène (3 rôles différents). + - Chord n'a pas de couche de scoring ni de consensus applicatif. + - La stabilisation Chord (jointures fréquentes) génère du trafic O(log² n). + oc-discovery évite cela par un pool statique d'indexeurs (StaticIndexers) + avec replenish seulement en cas de panne. + - Chord est mathématiquement élégant mais pratiquement fragile face aux + partitions (l'anneau peut se "casser"). oc-discovery tolère les partitions + via fallback + pool pré-validé. + +Source : Stoica, I., Morris, R., Karger, D., Kaashoek, M. F., & Balakrishnan, H. +(2001). "Chord: A Scalable Peer-to-Peer Lookup Service for Internet Applications." +ACM SIGCOMM 2001. + +---- + +2.3 Gnutella 2 / FastTrack — Supernœuds [Ritter, 2001] +-------------------------------------------------------- + +Description : FastTrack (réseau de Kazaa, iMesh) introduit les "supernœuds" : +des pairs capables de gérer des listes d'indexation pour les pairs ordinaires. +Les supernœuds forment un mesh entre eux, les pairs ordinaires se connectent +à un ou plusieurs supernœuds. Gnutella 2 adopte une architecture similaire +avec "hubs" et "leaves". + +Similitudes — c'est le point de corrélation le plus fort : + - La tripartition native/indexer/node de oc-discovery est structurellement + identique à la partition supernode/ultrapeers/leaves de FastTrack/Gnutella2. + - Les natifs forment un mesh entre eux (heartbeat bidirectionnel + gossip), + exactement comme les hubs Gnutella2 s'interconnectent. + - Les indexeurs s'enregistrent auprès des natifs exactement comme les leaves + se connectent à un hub Gnutella2. + - L'auto-sélection des supernœuds (selon la bande passante, uptime) préfigure + le scoring multidimensionnel de oc-discovery. + +Divergences : + - FastTrack/Gnutella2 sont ouverts : tout pair peut devenir supernœud si les + critères ressources sont satisfaits. oc-discovery est fermé : le rôle de + natif est configuré statiquement (NativeIndexerAddresses). Ce choix délibéré + améliore la sécurité (admission explicite) mais réduit l'élasticité. + - FastTrack n'a aucun mécanisme cryptographique de validation des entrées. + oc-discovery signe chaque IndexerRegistration et valide en DHT. + - FastTrack n'a pas de consensus : les supernœuds sont des autorités locales + non coordonnées. oc-discovery introduit clientSideConsensus (Phase 1) et + indexerLivenessVote (Phase 2) — inexistants dans Gnutella2. + - Le scoring de oc-discovery (5 composants, gap-aware uptime) est bien plus + sophistiqué que la simple métrique de bande passante de FastTrack. + +Source : Ritter, J. (2001). "Why Gnutella Can't Scale. No, Really." +Clip2 Distributed Search Solutions Technical Report. +CNN (2003). "KaZaA's Secret : Supernodes." + +---- + +2.4 IPFS / libp2p [Benet, 2014 ; libp2p, 2019] +------------------------------------------------ + +Description : IPFS (InterPlanetary File System) est un protocole de stockage +P2P adressé par contenu. Il utilise libp2p comme couche réseau, Kademlia DHT +(go-libp2p-kad-dht), GossipSub pour le pub/sub, et yamux pour le multiplexage. + +Similitudes — très fortes (stack identique) : + - oc-discovery utilise exactement la même pile : libp2p + yamux + Kademlia + + GossipSub. Les protocoles de transport, de mux, et de DHT sont les mêmes. + - La notion de PeerID Ed25519 auto-certifié est commune. + - Le mécanisme de PSK (private network) est une fonctionnalité libp2p standard + utilisée dans les déploiements IPFS privés. + - GossipSub avec TopicValidator (validation des messages avant acceptation) + est utilisé dans les deux systèmes. + +Divergences : + - IPFS est orienté adressage par contenu (CID, Merkle DAG). oc-discovery est + orienté découverte de pairs (PeerRecord, présence). + - IPFS utilise la DHT Kademlia comme plan de découverte principal (findProviders, + findPeers). oc-discovery relègue la DHT à un rôle de persistance secondaire : + le plan principal est le mesh natif + heartbeat long-lived (latence < 1 ms + vs 50–200 ms DHT). + - IPFS n'a pas de scoring d'admission. Tout pair libp2p peut publier du contenu. + - IPFS n'a pas de concept d'indexeur ou de natif. La hiérarchie fonctionnelle + de oc-discovery est absente — IPFS est architecturalement flat. + - IPFS ne maintient pas de présence continue (heartbeat). La présence est + inférée par la disponibilité DHT (providers). oc-discovery maintient une + présence explicite avec TTL court (2 min) et GC. + - L'espace de noms DHT de oc-discovery est privé (préfixe "oc") : il ne se + mélange pas au réseau DHT IPFS public. + +Source : Benet, J. (2014). "IPFS - Content Addressed, Versioned, P2P File System." +arXiv:1407.3561. +libp2p (2019). libp2p specifications. https://github.com/libp2p/specs + +---- + +2.5 Ethereum devp2p / DiscV5 [Ethereum Foundation, 2014–2021] +-------------------------------------------------------------- + +Description : Le protocole de découverte Ethereum (devp2p) utilise une DHT +Kademlia modifiée (initialement discv4, puis discv5). Les nœuds s'annoncent +via des ENR (Ethereum Node Records) signés. discv5 introduit des topics (TOPICQUERY) +pour la découverte par type de service. + +Similitudes : + - ENR (Ethereum Node Records) signés sont analogues aux IndexerRegistration + signés de oc-discovery : preuve cryptographique que l'annonceur contrôle + l'adresse déclarée (Sign(PeerID|Addr|Nonce)). + - La notion de "bootstrap nodes" fixes (Ethereum Foundation opère des nodes + DNS/statiques) est analogue aux NativeIndexerAddresses configurés statiquement. + - discv5 TOPICQUERY permet de filtrer les pairs par capability, analogue au + GetIndexersRequest{Count: n} de oc-discovery. + +Divergences : + - Ethereum vise un réseau ouvert et mondial (des milliers de nœuds). oc-discovery + est un réseau privé fermé (PSK), pensé pour des déploiements organisationnels. + - discv5 est purement flat : aucune hiérarchie native/indexer/node. + - Ethereum n'a pas de scoring de confiance d'admission. La sélection est + purement Kademlia (proximité XOR des PeerIDs). + - Ethereum n'a pas de mécanisme de liveness vote comparable à Phase 2 de + oc-discovery. La vivacité est inférée par les échecs de connexion, non + par un vote explicite. + - Le consensus applicatif Ethereum (PoW/PoS) est au niveau de la chaîne, + pas au niveau de la découverte. oc-discovery intègre un consensus directement + dans la couche de découverte (clientSideConsensus). + +Source : Ethereum Foundation (2021). "Node Discovery Protocol v5." +https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md + +---- + +2.6 Bitcoin P2P / DNS Seeds [Nakamoto, 2008 ; Bitcoin Core] +------------------------------------------------------------ + +Description : Bitcoin utilise un réseau P2P flat avec un protocole de gossip +ad hoc (ADDR messages). Au démarrage, un nœud se connecte à des "DNS seeds" +(noms DNS opérés par des développeurs réputés) qui retournent une liste de pairs +actifs. Le pair se connecte ensuite à N pairs au hasard. + +Similitudes : + - Les DNS seeds de Bitcoin sont fonctionnellement analogues aux IndexerAddresses + (seeds) de oc-discovery : liste de points d'entrée configurés statiquement + pour bootstrapper la découverte. + - Le mode seed de oc-discovery (IndexerAddresses sans natif, AdmittedAt=0) + est structurellement analogue au mode bootstrap-DNS de Bitcoin : confiance + explicite hors-bande, pas de validation cryptographique. + +Divergences : + - Bitcoin est entièrement flat. Aucune hiérarchie, aucun rôle privilégié. + - Bitcoin n'a pas de scoring ni de consensus de découverte. Le choix des pairs + est pseudo-aléatoire (évitement du clustering). + - Les DNS seeds Bitcoin sont des points de centralisation opérationnelle + (opérés par quelques individus connus). Le mode natif de oc-discovery + distribue cette autorité entre plusieurs natifs avec consensus. + - Bitcoin tolère les Sybil attacks par PoW. oc-discovery tolère les Sybil + par PSK + signature cryptographique + quorum natif. + - oc-discovery détecte explicitement la panne d'un pair via heartbeat (20 s). + Bitcoin détecte via timeout de connexion (2 h par défaut). + +Source : Nakamoto, S. (2008). "Bitcoin: A Peer-to-Peer Electronic Cash System." +Lopp, J. (2016). "Bitcoin Node Network." Statoshi.info. + +---- + +2.7 Consul (HashiCorp) — Service Discovery [HashiCorp, 2014] +------------------------------------------------------------- + +Description : Consul est un service de découverte et de configuration distribué. +Il utilise le consensus Raft au sein d'un cluster de serveurs (3 ou 5 nœuds), +et le protocole Serf (gossip SWIM) pour la détection de défaillances entre +agents. Les services s'enregistrent auprès des agents locaux qui répliquent +vers les serveurs Raft. + +Similitudes : + - La couche de health checking de Consul (SWIM : heartbeats périodiques + + indirect probing) est fonctionnellement analogue au heartbeat long-lived de + oc-discovery pour détecter les défaillances. + - L'enregistrement de service avec TTL (service registration + TTL check) est + analogue à IndexerRegistration + IndexerTTL (90 s). + - Le concept de "critical service" qui expire si l'agent ne renouvelle pas + le check TTL est analogue à la logique de GC de oc-discovery (now.After(Expiry)). + +Divergences : + - Consul repose sur Raft pour la cohérence forte (CP dans le théorème CAP). + oc-discovery fait un choix AP : disponibilité et cohérence à terme, tolérant + les lectures potentiellement stales (cache mémoire natif vs DHT désynchronisée). + - Consul requiert un quorum Raft de serveurs (minimum 3) pour fonctionner. + oc-discovery fonctionne avec un seul natif (mode dégradé accepté). + - Consul est un service centralisé (cluster de serveurs). oc-discovery est + vraiment décentralisé : les natifs sont des pairs égaux sans leader élu. + - Consul s'intègre typiquement dans un data center (LAN). oc-discovery est + conçu pour un réseau WAN avec NAT traversal, PSK, et des RTT élevés. + - Consul n'a pas de scoring de confiance des services : tout service enregistré + par un agent authentifié est valide. oc-discovery ajoute 5 composantes de score. + +Source : HashiCorp (2014). "Consul: Service Mesh Solution." +https://developer.hashicorp.com/consul/docs/architecture +Maciej Dobrzański (2015). "Consul: HashiCorp's Service Discovery Tool." InfoQ. + +---- + +2.8 ZooKeeper [Hunt et al., 2010] +---------------------------------- + +Description : ZooKeeper est un service de coordination distribué utilisant un +consensus ZAB (ZooKeeper Atomic Broadcast, dérivé de Paxos). Il maintient un +espace de noms hiérarchique (znodes) avec watches. Utilisé pour la découverte +de services (enregistrement d'éphémères), leader election, configuration. + +Similitudes : + - Les znodes éphémères de ZooKeeper (supprimés quand le client se déconnecte) + sont fonctionnellement analogues aux entrées liveIndexers avec TTL : dans + les deux cas, la présence est inférée par la connexion active. + - La notion de watch (notification de changement) est analogue au nudge channel + de oc-discovery (indexerHeartbeatNudge, nativeHeartbeatNudge). + +Divergences : + - ZooKeeper est fondamentalement centralisé : il requiert un ensemble de serveurs + (ensemble) avec élection de leader via ZAB. C'est un service CP fort. + oc-discovery n'a pas de leader, pas d'élection, pas de log répliqué. + - ZooKeeper offre des garanties de linéarisabilité pour les écritures. oc-discovery + n'offre qu'une cohérence à terme (liveIndexers vs DHT peuvent diverger jusqu'à + ~30 s). + - ZooKeeper est conçu pour des réseaux fiables (LAN intra-datacenter). oc-discovery + tolère des partitions WAN longues via fallback pool + retryLostNative. + - ZooKeeper n'a aucun mécanisme de scoring ou de confiance : toute session + authentifiée peut écrire des znodes dans son espace. + +Source : Hunt, P., Konar, M., Junqueira, F. P., & Reed, B. (2010). "ZooKeeper: +Wait-free Coordination for Internet-scale Systems." USENIX ATC 2010. + +---- + +2.9 Gossip / SWIM (Scalable Weakly-consistent Infection-style Membership) +[Das et al., 2002] +--------------------------------------------------------------------------- + +Description : SWIM est un protocole d'appartenance (membership) à base de gossip +utilisé dans Consul/Serf, Cassandra, etcd (memberlist). Chaque nœud envoie des +pings directs et des pings indirects (via tiers) pour détecter les défaillances. +L'information se propage par infection (gossip) en O(log n) rounds. + +Utilisation dans oc-discovery : Le gossip GossipSub (oc-indexer-registry) de +oc-discovery disséminate les IndexerRegistration signées entre natifs lorsqu'un +indexeur s'enregistre. Ce sous-protocole joue un rôle SWIM partiel. + +Similitudes : + - Dissémination par gossip (GossipSub avec random fanout) est formellement + équivalente à l'infection-style de SWIM : chaque message atteint tous les + nœuds en O(log n) rounds avec haute probabilité. + - La détection de défaillance par timeout de heartbeat (2 min dans oc-discovery) + est analogue au timeout de SWIM avant de déclarer un membre "suspect". + - Le TopicValidator de oc-discovery (validation de l'émetteur + signature) + est analogue au message authentication dans les implémentations sécurisées de SWIM. + +Divergences : + - SWIM implémente un "indirect probing" (demander à un tiers de pinger le + suspect avant de le déclarer mort) pour réduire les faux positifs. oc-discovery + n'a pas d'indirect probing : la défaillance est déclarée dès que le heartbeat + échoue directement (false positives possibles sous partition partielle). + - SWIM gère l'appartenance de manière symétrique (tout nœud peut diagnostiquer + tout autre). oc-discovery est asymétrique : seuls les natifs valident les + indexeurs ; les nœuds ne se valident pas mutuellement. + - Le gossip oc-discovery ne porte que les enregistrements d'indexeurs (plan + de contrôle). SWIM gossipe l'état de tout le membership. + +Source : Das, A., Gupta, I., & Motivala, A. (2002). "SWIM: Scalable Weakly- +consistent Infection-style Process Group Membership Protocol." DSN 2002. + +---- + +2.10 Tor — Onion Routing [Dingledine et al., 2004] +---------------------------------------------------- + +Description : Tor est un réseau d'anonymisation par routage en oignon. Un +répertoire d'autorités (directory authorities) — 9 nœuds fixes connus de tous — +publie un "consensus" signé des relais disponibles (adresse, bande passante, +flags). Les clients téléchargent ce consensus et choisissent un circuit de 3 relais. + +Similitudes : + - Les directory authorities de Tor sont structurellement analogues aux native + indexers de oc-discovery : nœuds privilégiés, fixes, formant un mesh + autoritaire qui publie un état de santé du réseau. + - Le consensus signé de Tor (vote majority parmi les authorities) est le + mécanisme le plus proche de clientSideConsensus de oc-discovery : les + authorities votent sur quels relais sont valides, oc-discovery vote sur + quels indexeurs sont valides. + - Les flags Tor (Guard, Exit, HSDir, Stable, Fast) jouent un rôle analogue au + scoring de confiance de oc-discovery : ils qualifient les nœuds selon leurs + performances (bande passante, uptime, stabilité). + - La bande passante comme critère de sélection des relais Tor est analogue au + composant B (bpms) du score de oc-discovery. + - L'uptime comme critère de stabilité Tor ("Stable" flag : médiane des temps + de session) est analogue au composant U (UptimeRatio gap-aware) de oc-discovery. + +Divergences : + - Les directory authorities Tor sont en nombre fixe (9) et leurs clés sont + hardcodées dans le client Tor. oc-discovery permet un nombre variable de + natifs, configuré par l'opérateur — plus flexible mais moins résistant à une + reconfiguration malveillante. + - Tor vise l'anonymat : il évite que les relais connaissent la source ET la + destination. oc-discovery n'a aucun objectif d'anonymat : les indexeurs + connaissent l'identité de tous les nœuds qui les heartbeatent. + - Le consensus Tor est calculé toutes les heures (document voté entre authorities). + oc-discovery recalcule la confiance à chaque heartbeat (20 s) — beaucoup plus + réactif. + - Tor est open : tout pair peut devenir un relais (il suffit de déclarer son + adresse). oc-discovery est fermé (PSK + rôle de natif configuré statiquement). + +Source : Dingledine, R., Mathewson, N., & Syverson, P. (2004). "Tor: The Second- +Generation Onion Router." USENIX Security 2004. + +---- + +2.11 Hyperledger Fabric — Gossip Service [Androulaki et al., 2018] +------------------------------------------------------------------ + +Description : Hyperledger Fabric est un blockchain permissionné. Son protocole +gossip propage les blocs et les données d'appartenance entre pairs d'une +organisation. Des "anchor peers" jouent le rôle d'intermédiaires entre organisations. + +Similitudes : + - Les anchor peers de Fabric sont analogues aux native indexers de oc-discovery : + points de contact fixes et stables qui relaient les informations entre + participants d'organisations différentes. + - Le gossip permissionné (membership service provider + certificats) est analogue + au PSK + signatures de oc-discovery : seuls les pairs authentifiés participent. + - La notion de "private data collection" (données partagées qu'avec un sous-ensemble) + est analogue au scope privé de oc-discovery (PSK isole le réseau). + +Divergences : + - Fabric est orienté transaction et consensus d'ordre (orderer service, Raft). + oc-discovery est orienté présence et découverte sans log ordonné. + - Fabric utilise TLS avec certificats X.509 (PKI gérée). oc-discovery utilise + des clés Ed25519 auto-certifiées (pas de PKI, pas d'autorité de certification + tierce). + - Fabric distingue "endorse → order → commit" avec finalité forte. oc-discovery + accepte la cohérence à terme pour favoriser la disponibilité. + +Source : Androulaki, E., et al. (2018). "Hyperledger Fabric: A Distributed +Operating System for Permissioned Blockchains." EuroSys 2018. + +---- + +2.12 Cassandra / Dynamo — Gossip + Ring [DeCandia et al., 2007] +---------------------------------------------------------------- + +Description : Amazon Dynamo et Apache Cassandra utilisent un anneau de hachage +cohérent (consistent hashing) pour distribuer les données, avec gossip pour +l'appartenance au cluster (chaque nœud connaît l'état de santé de tous les autres). +Cassandra utilise SWIM-like pour la détection de défaillances. + +Similitudes : + - Le hachage cohérent de Cassandra (distribution des données sans point central) + est analogue à la DHT Kademlia de oc-discovery pour la distribution des + enregistrements /node. + - Le gossip d'appartenance de Cassandra (Endpoint State → état de santé de + chaque nœud diffusé à tous) est analogue au GossipSub de oc-discovery pour + la diffusion des IndexerRegistration signées. + - La réplication configurable de Cassandra (replication factor) est analogue + à la réplication Kademlia (k-buckets) pour la tolérance aux pannes. + +Divergences : + - Cassandra vise la disponibilité et la tolérance aux pannes pour des données + persistantes (AP dans le théorème CAP). Ses écritures sont durables. + oc-discovery ne persiste que des données de présence éphémères (TTL court). + - Cassandra n'a aucun mécanisme d'admission ou de scoring des pairs. Tout nœud + connaissant le token ring peut rejoindre le cluster. + - La hiérarchie de oc-discovery (3 niveaux) est absente dans Cassandra (flat). + +Source : DeCandia, G., et al. (2007). "Dynamo: Amazon's Highly Available Key-value +Store." ACM SIGOPS SOSP 2007. + +---- + +2.13 Pastry / Tapestry / Bamboo [Rowstron & Druschel, 2001] +------------------------------------------------------------ + +Description : Famille de DHT de 2e génération avec routage par préfixe. Pastry +ajoute le concept de "leaf set" (voisins proches dans l'espace de noms) et de +"neighborhood set" (voisins géographiques). Tapestry (Yale) ajoute un routage +multi-hop avec racines locales pour la localité. + +Similitudes : + - Le "leaf set" de Pastry (nœuds proches maintenus de façon persistante) est + analogue au pool StaticIndexers de oc-discovery : un ensemble stable de pairs + "proches" fonctionnellement. + - La notion de "neighborhood set" (localité géographique) est analogue au + composant D (diversité /24 de oc-discovery) qui mesure la diversité topologique. + +Divergences : + - Pastry/Tapestry sont conçus pour des réseaux à grande échelle (millions de + nœuds). oc-discovery est conçu pour des déploiements organisationnels (centaines + à quelques milliers de nœuds). + - Pastry n'a aucun mécanisme de scoring ni de consensus de confiance. + - La hiérarchie de oc-discovery est fonctionnelle (les rôles sont configurés), + non émergente comme dans Pastry (le "leaf set" est purement géométrique). + +Source : Rowstron, A. & Druschel, P. (2001). "Pastry: Scalable, decentralized +object location and routing for large-scale peer-to-peer systems." +Middleware 2001, Springer LNCS 2218. + +---- + +2.14 Wireguard / Tailscale — PSK + PKI [Donenfeld, 2017] +--------------------------------------------------------- + +Description : Wireguard est un protocole VPN léger basé sur des clés statiques +Curve25519 et un handshake Noise. Tailscale construit un overlay P2P sur Wireguard +avec un plan de contrôle centralisé (coordination server) pour l'échange de clés +et la découverte de pairs. + +Similitudes : + - L'isolation par PSK de oc-discovery est conceptuellement analogue à l'isolation + par clé partagée de Wireguard : seuls les pairs possédant la clé peuvent + établir une connexion. + - La clé Ed25519 auto-certifiée de libp2p est analogue à la clé statique + Curve25519 de Wireguard : identité = clé, aucune PKI tierce nécessaire. + +Divergences : + - Wireguard est un tunnel L3 (plan de données). oc-discovery est un protocole + applicatif (plan de contrôle / découverte). Ils opèrent à des couches différentes. + - Tailscale centralise la découverte (coordination server). oc-discovery distribue + cette fonction sur le mesh natif. + - oc-discovery n'implémente pas de confidentialité des flux applicatifs (les + messages JSON circulent en clair sur le transport libp2p TCP, protégé uniquement + par la PSK réseau, pas par un chiffrement de bout en bout des payloads). + +Source : Donenfeld, J. A. (2017). "WireGuard: Next Generation Kernel Network +Tunnel." NDSS 2017. + +---- + +2.15 Polkadot — Parachain Discovery [Wood, 2016] +------------------------------------------------- + +Description : Polkadot utilise libp2p pour son transport, avec une couche de +découverte basée sur Kademlia pour trouver les pairs de chaque parachain. +La chaîne relais (Relay Chain) joue un rôle de coordination autoritaire entre +les parachains (validation, finalité). + +Similitudes très fortes (stack identique) : + - Polkadot utilise exactement la même pile : libp2p, yamux, Kademlia, GossipSub. + - La Relay Chain de Polkadot est structurellement analogue aux native indexers + de oc-discovery : autorité de coordination entre participants. + - Les validateurs Polkadot (nœuds stables et bien capitalisés) sont analogues + aux native indexers (nœuds stables et bien connectés). + - Le protocole GRANDPA (finalité par vote de validateurs) partage avec + clientSideConsensus le principe de vote majoritaire sur un état proposé. + +Divergences : + - Polkadot est un protocole de blockchain : son consensus (BABE + GRANDPA) vise + la finalité économique (transactions). oc-discovery vise la découverte de + pairs — aucune finalité économique, aucune donnée persistante à long terme. + - Polkadot intègre un mécanisme de staking économique (preuve d'enjeu) pour + punir les validateurs malveillants. oc-discovery utilise uniquement des + mécanismes cryptographiques (signature + quorum). + +Source : Wood, G. (2016). "Polkadot: Vision for a Heterogeneous Multi-chain +Framework." Whitepaper v1. + + +================================================================================ +3. PATTERNS ARCHITECTURAUX RECONNUS +================================================================================ + +3.1 Supernode / Tiered P2P (FastTrack, Gnutella2, Skype) +--------------------------------------------------------- + +Pattern : Séparation des participants en tiers selon leurs capacités. +Les supers-nœuds forment un mesh stable et gèrent les listes d'appartenance +des nœuds ordinaires. + +oc-discovery : ✅ Correspond exactement. Native = supernode, Indexer = intermédiaire, +Node = feuille. La hiérarchie est fonctionnelle (non pyramidale) : un natif peut +devenir indexeur, un indexeur peut agir comme nœud. + +Divergence clé : dans les systèmes classiques (FastTrack, Skype), les supernœuds +émergent de la topologie (auto-sélection selon la bande passante). Dans oc-discovery, +les natifs sont configurés statiquement — choix délibéré pour la sécurité et la +prévisibilité, au détriment de l'élasticité. + +---- + +3.2 Gossip Membership (SWIM, Cassandra, Consul/Serf) +----------------------------------------------------- + +Pattern : Dissémination par infection épidémique. Chaque nœud sélectionne +aléatoirement k voisins à chaque round et leur envoie son état local. L'information +atteint tous les nœuds en O(log n) rounds avec haute probabilité. + +oc-discovery : ✅ Partiellement. GossipSub (oc-indexer-registry) implémente ce +pattern entre natifs pour disséminer les IndexerRegistration. GossipSub est une +implémentation optimisée avec un graphe de gossip stable (mesh overlay) + graft/prune. + +Divergence : le gossip de oc-discovery ne porte que les événements d'enregistrement +d'indexeurs (plan de contrôle). Il ne porte pas l'état de santé global du réseau +(membership complet) comme SWIM. La vivacité des indexeurs est déterminée +par le heartbeat direct, pas par gossip. + +---- + +3.3 DHT Kademlia avec namespacing (IPFS, Ethereum, Polkadot) +------------------------------------------------------------- + +Pattern : Table de hachage distribuée à résolution en O(log n), avec espaces de +noms séparés pour différents types de données. Validateurs customisés par namespace. + +oc-discovery : ✅ Implémenté. 4 espaces : /node, /indexer, /name, /pid. Chaque +espace a son propre validateur (PeerRecordValidator, IndexerRecordValidator, +DefaultValidator). La DHT est en mode Server (tous les indexeurs/natifs participent +au routage). + +Divergence notable : la DHT est secondaire dans oc-discovery (lecture lente de +secours), contrairement à IPFS où elle est le plan principal. Ce choix favorise +la latence de découverte (cache mémoire natif < 1 ms vs DHT 50–200 ms) mais +introduit une désynchronisation potentielle (split-brain DHT/cache jusqu'à 30 s). + +---- + +3.4 Consensus par vote majoritaire (Tor, Raft, PBFT, Tendermint) +----------------------------------------------------------------- + +Pattern : Un ensemble de participants vote sur un état proposé. Un état est accepté +si plus de q × |voters| participants l'approuvent (q > 0.5 pour tolérance byzantine, +q > 0.5 pour crash-fault). + +oc-discovery : ✅ Implémenté en deux phases : + Phase 1 (clientSideConsensus) : vote des natifs sur les candidats-indexeurs. + Phase 2 (indexerLivenessVote) : vote des indexeurs stables sur la vivacité. + +Divergence majeure vs Raft/PBFT : le consensus de oc-discovery est stateless +et one-shot — chaque appel à ConnectToNatives/replenishIndexersFromNative lance +un round de vote indépendant. Il n'y a pas de log répliqué, pas de leader élu, +pas de finalité forte. C'est un consensus light (discovery-grade) adapté à la +découverte de pairs, pas à l'accord sur des transactions. + +---- + +3.5 Self-healing / Auto-recovery (Erlang OTP, Kubernetes, etcd) +---------------------------------------------------------------- + +Pattern : Le système détecte ses propres défaillances et se répare sans intervention +externe. Acteurs superviseurs (Erlang), liveliness/readiness probes (k8s), leader +re-election (etcd). + +oc-discovery : ✅ Implémenté. Quatre mécanismes de self-healing : + (a) replenishIndexersFromNative : panne indexeur → remplacement automatique. + (b) replenishNativesFromPeers : panne natif → recherche de remplaçant. + (c) retryLostNative : ticker 30 s pour réessayer un natif inaccessible. + (d) IsSelfFallback : natif sans indexeur → s'auto-désigne + runOffloadLoop. + +Divergence : Erlang/k8s ont un superviseur externe au composant défaillant +(out-of-process). oc-discovery est self-supervising : chaque goroutine gère +ses propres défaillances (doTick → err → delete + replenish). Cela simplifie +l'architecture mais rend les défaillances en cascade plus difficiles à isoler. + +---- + +3.6 Trust Scoring / Reputation (BitTorrent, Web of Trust PGP, EigenTrust) +-------------------------------------------------------------------------- + +Pattern : Attribution d'un score de réputation aux pairs selon des métriques +observables (comportement passé, connectivité, uptime). Les pairs à faible score +sont exclus ou dé-priorisés. + +oc-discovery : ✅ Implémenté. Score 5 composants avec seuil dynamique : + S = (0.20×U + 0.20×B + 0.20×D + 0.15×L + 0.25×F) × 100 + minScore(age) = 20 + 60×min(age/24h, 1) + +Similitude avec EigenTrust [Kamvar et al., 2003] : les deux systèmes normalisent +les scores entre 0 et 1, utilisent l'uptime comme signal de fiabilité, et intègrent +un historique temporel. + +Divergence majeure : EigenTrust est itératif (convergence en plusieurs rounds de +gossip). oc-discovery calcule le score localement à chaque heartbeat sans +propagation globale. C'est un score observable local (l'indexeur mesure ses propres +pairs), non un score global agrégé par le réseau. + +Divergence vs BitTorrent : BitTorrent utilise un score d'échange ("tit-for-tat") +basé sur les octets reçus. oc-discovery mesure la qualité de connexion (latence, +bande passante, uptime, diversité topologique) — des métriques de qualité de +service, pas de réciprocité de comportement. + +Source : Kamvar, S. D., Schlosser, M. T., & Garcia-Molina, H. (2003). +"The EigenTrust Algorithm for Reputation Management in P2P Networks." +WWW 2003. + +---- + +3.7 Long-lived Bidirectional Streams (gRPC server-side streaming, WebSocket) +----------------------------------------------------------------------------- + +Pattern : Réutilisation d'une connexion persistante pour des échanges multiples, +évitant le coût de connexion (TCP SYN+ACK + TLS 1-RTT) à chaque message. + +oc-discovery : ✅ Implémenté. /opencloud/heartbeat/1.0 maintient un stream yamux +persistant pour la durée de vie du pair. Économie estimée : 3 RTT par tick vs +une connexion fresh. + +Convergence avec gRPC server-side streaming : même concept — un client ouvre un +stream et le serveur pushed des messages periódicamente. La différence : gRPC est +orienté RPC (request-response unique dans la direction inverse). oc-discovery est +purement unidirectionnel (node → indexer, pas de réponse applicative sur le stream +heartbeat). + + +================================================================================ +4. TABLEAU COMPARATIF SYNTHÉTIQUE +================================================================================ + +Légende : + ✅ présent / similaire ⚠️ partiel / approximatif ❌ absent + +┌─────────────────────────────────┬────────┬──────────┬────────┬───────┬────────┬────────┬────────┬──────────┐ +│ Critère │oc-disc │Kademlia │FastTrk │Consul │ IPFS │Tor │Ethereum│ZooKeeper │ +├─────────────────────────────────┼────────┼──────────┼────────┼───────┼────────┼────────┼────────┼──────────┤ +│ Réseau privé / fermé (PSK) │ ✅ │ ❌ │ ❌ │ ⚠️ │ ⚠️ │ ❌ │ ❌ │ ⚠️ │ +│ Hiérarchie fonctionnelle 3 niv. │ ✅ │ ❌ │ ✅ │ ⚠️ │ ❌ │ ✅ │ ❌ │ ❌ │ +│ DHT Kademlia │ ✅ │ ✅ │ ❌ │ ❌ │ ✅ │ ❌ │ ✅ │ ❌ │ +│ Gossip dissémination │ ✅ │ ❌ │ ✅ │ ✅ │ ✅ │ ⚠️ │ ✅ │ ❌ │ +│ Heartbeat long-lived (stream) │ ✅ │ ❌ │ ❌ │ ⚠️ │ ❌ │ ❌ │ ❌ │ ✅ │ +│ Scoring de confiance multi-dim. │ ✅ │ ❌ │ ⚠️ │ ❌ │ ❌ │ ⚠️ │ ❌ │ ❌ │ +│ Consensus de découverte (vote) │ ✅ │ ❌ │ ❌ │ ✅ │ ❌ │ ✅ │ ❌ │ ✅ │ +│ Liveness vote 2 phases │ ✅ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ +│ Signature cryptographique entrée│ ✅ │ ❌ │ ❌ │ ⚠️ │ ✅ │ ✅ │ ✅ │ ❌ │ +│ TTL + GC des entrées │ ✅ │ ⚠️ │ ❌ │ ✅ │ ⚠️ │ ✅ │ ❌ │ ✅ │ +│ Self-healing automatique │ ✅ │ ❌ │ ❌ │ ✅ │ ⚠️ │ ❌ │ ⚠️ │ ✅ │ +│ Self-fallback (native→indexer) │ ✅ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ +│ Consensus fort (Raft/PBFT) │ ❌ │ ❌ │ ❌ │ ✅ │ ❌ │ ⚠️ │ ✅ │ ✅ │ +│ Seuil d'admission dynamique │ ✅ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ ❌ │ +│ UptimeTracker gap-aware │ ✅ │ ❌ │ ❌ │ ❌ │ ❌ │ ⚠️ │ ❌ │ ❌ │ +│ Fill rate routing (bell curve) │ ✅ │ ❌ │ ❌ │ ⚠️ │ ❌ │ ❌ │ ❌ │ ❌ │ +│ Pas de PKI tierce │ ✅ │ ✅ │ ✅ │ ❌ │ ✅ │ ❌ │ ✅ │ ❌ │ +│ Découverte bootstrapable > 0 │ ✅ │ ⚠️ │ ✅ │ ⚠️ │ ✅ │ ✅ │ ✅ │ ⚠️ │ +│ Résistance à la partition WAN │ ✅ │ ⚠️ │ ⚠️ │ ❌ │ ✅ │ ✅ │ ⚠️ │ ❌ │ +│ Scalabilité > 10K nœuds │ ⚠️ │ ✅ │ ✅ │ ⚠️ │ ✅ │ ✅ │ ✅ │ ⚠️ │ +│ Anonymat / confidentialité pairs│ ❌ │ ❌ │ ❌ │ ❌ │ ⚠️ │ ✅ │ ❌ │ ❌ │ +└─────────────────────────────────┴────────┴──────────┴────────┴───────┴────────┴────────┴────────┴──────────┘ + + +================================================================================ +5. ANALYSE DES DIVERGENCES STRUCTURELLES FONDAMENTALES +================================================================================ + +5.1 La hiérarchie sans autorité centrale — originalité principale +------------------------------------------------------------------ + +oc-discovery réussit un équilibre que peu de systèmes atteignent : une hiérarchie +fonctionnelle à 3 niveaux sans point de centralisation du contrôle. Tor s'en +approche le plus avec ses 9 directory authorities — mais leurs clés sont hardcodées +(décision centralisée). FastTrack et Gnutella2 ont une hiérarchie émergente mais +sans mécanisme de consensus. + +La combinaison (hiérarchie statiquement configurée) + (consensus dynamique entre +pairs de même niveau) + (fallback autonome sans coordinateur) est, à notre +connaissance, sans équivalent direct dans les systèmes existants pour la couche +de découverte de services. + +5.2 Le score de confiance comme filtre d'admission — contraste avec DHT pures +------------------------------------------------------------------------------ + +Les DHT pures (Kademlia, Chord, Pastry) n'ont aucun mécanisme d'admission : +tout pair qui connaît le bootstrap peut joindre le réseau. BitTorrent ajoute +le "tit-for-tat" mais c'est un mécanisme de réciprocité, pas de qualité. +EigenTrust est global (agrégation par gossip), non local. + +oc-discovery est rare dans sa catégorie : il applique un score de qualité +multi-composant (5 métriques) calculé localement par l'indexeur receveur, +avec un seuil dynamique qui s'adapte à la maturité du pair. Cela ressemble +davantage à une logique de SLA d'entreprise qu'à un protocole P2P académique. + +5.3 Le consensus en deux phases — séparation admission/vivacité +--------------------------------------------------------------- + +Aucun système de découverte P2P parmi ceux étudiés ne sépare explicitement +l'admission (validation d'identité par un tier de confiance) et la vivacité +(validation de présence par les pairs actuels). Cette séparation (principe D19) +est une contribution architecturale originale. + +Tor s'en approche le plus : les directory authorities valident l'identité des +relais (admission), et le consensus horaire inclut des métriques de disponibilité +récente (vivacité), mais les deux étapes sont fondues dans un seul vote horaire +des mêmes acteurs, sans séparation structurelle des responsabilités. + +5.4 La contrainte PSK — réseau organisationnel, non universel +------------------------------------------------------------- + +Le PSK rend oc-discovery fondamentalement différent de tous les systèmes P2P +"ouverts" (IPFS, Ethereum, Bitcoin). C'est un réseau de confiance délimitée, +plus proche des VPN d'entreprise ou des blockchains permissionnées (Hyperledger) +que des réseaux P2P académiques. Cette contrainte simplifie considérablement la +gestion des Sybil attacks (impossibles sans PSK) mais impose une gestion hors-bande +de la distribution de la clé. + +5.5 Limites par rapport aux systèmes comparés +---------------------------------------------- + +(a) Scalabilité : oc-discovery n'a pas été évalué au-delà de quelques centaines + de nœuds. Kademlia, IPFS, et Ethereum sont validés à des millions de nœuds. + Le mesh natif (topologie complète O(n²) entre natifs) ne passera pas à l'échelle + de centaines de natifs. + +(b) Indirect probing absent : contrairement à SWIM/Consul, la détection de + défaillance de oc-discovery est directe (heartbeat failure = dead). Un + intermédiaire réseau défaillant peut créer de faux positifs. + +(c) Cohérence à terme seulement : contrairement à Consul (Raft CP) ou ZooKeeper + (ZAB CP), oc-discovery offre uniquement une cohérence à terme. Pour des + applications nécessitant une garantie forte de cohérence (config critique, + PKI), un système CP est préférable. + +(d) Anonymat absent : Tor garantit l'anonymat des clients. oc-discovery expose + l'identité de tous les pairs à tous les indexeurs (intentionnel : registre + d'identités, pas d'anonymisation). + + +================================================================================ +6. POSITIONNEMENT DANS L'ESPACE DES DESIGNS +================================================================================ + + Axe 1 : Ouvert ←——————————————————→ Fermé (PSK) + Kademlia, IPFS, Ethereum oc-discovery, Hyperledger Fabric + + Axe 2 : Flat ←————————————————————→ Hiérarchique + Kademlia, Bitcoin, Cassandra oc-discovery, Tor, FastTrack + + Axe 3 : AP (disponibilité) ←———————→ CP (cohérence forte) + oc-discovery, IPFS, Dynamo Consul, ZooKeeper, etcd + + Axe 4 : Score de confiance absent ←→ Présent + Kademlia, Bitcoin, Chord oc-discovery, Tor (flags), EigenTrust + + Axe 5 : Court TTL / éphémère ←—————→ Long TTL / persistant + oc-discovery (2min–90s) Cassandra, DHT IPFS (jusqu'à 24h) + +oc-discovery occupe une position distinctive : + - Fermé (PSK) comme Hyperledger, mais sans PKI centralisée. + - Hiérarchique comme Tor et FastTrack, mais avec consensus entre les hubs. + - AP comme IPFS et Dynamo, mais avec des TTL très courts pour les données + de présence (2 min vs heures/jours). + - Scoring comme Tor et EigenTrust, mais calculé localement (non global). + - Self-healing complet comme Consul et k8s, mais sans orchestrateur externe. + + +================================================================================ +7. RÉSUMÉ +================================================================================ + +oc-discovery est un système hybride qui emprunte à plusieurs familles de systèmes +décentralisés sans s'identifier pleinement à aucun : + + - À FastTrack/Gnutella2 : la hiérarchie supernode/leaf. + - À IPFS/Ethereum : la pile libp2p + Kademlia + GossipSub. + - À Tor : le consensus entre autorités pour valider les participants, + et l'utilisation de l'uptime/bande passante comme critères de qualité. + - À Consul/SWIM : les heartbeats de détection de défaillance et le GC par TTL. + - À EigenTrust : le scoring multi-composant de confiance des pairs. + - À ZooKeeper/Raft : l'idée d'un quorum de vote pour valider un état proposé. + +Les principales contributions originales de oc-discovery par rapport à l'état +de l'art sont : + + 1. La séparation structurelle admission (native) / vivacité (indexer) — D19. + 2. Le score dynamique gap-aware avec seuil adaptatif à la maturité du pair. + 3. Le routage fill rate par courbe en cloche (w(F) = F×(1-F)). + 4. Le mode IsSelfFallback + runOffloadLoop pour la continuité sans indexeur. + 5. La combinaison PSK + signatures auto-certifiées sans PKI tierce. + + +================================================================================ +8. NOTE MÉTHODOLOGIQUE +================================================================================ + +Cette analyse est basée sur une lecture directe des spécifications et publications +académiques référencées. Les comparaisons portent sur les propriétés architecturales +et les mécanismes de protocole, non sur les performances mesurées (oc-discovery +n'ayant pas fait l'objet d'un benchmark à large échelle à la date de rédaction). + +Les appréciations ✅/⚠️/❌ dans le tableau comparatif reflètent une analyse +qualitative et peuvent nécessiter une révision en fonction des évolutions +respectives des systèmes comparés. + + +================================================================================ +9. TRAJECTOIRE D'ÉVOLUTION : VERS UN ANNUAIRE NATIF DYNAMIQUE +================================================================================ + +9.1 Limite actuelle : le pool de natives est statique +------------------------------------------------------ + +Dans la version actuelle d'oc-discovery, les native indexers sont connus au +démarrage via configuration (StaticNatives). Ce pool évolue partiellement au +runtime (replenishNativesFromPeers) mais reste ancré à une liste d'entrée fixe. + +Cette contrainte implique que : + + - Chaque native connaît potentiellement tous les indexers → état O(N indexers) + - Le consensus inter-natives est O(natives²) en communication + - Les natives sont des hubs structurellement privilegiés → SPOFs relatifs + - L'ajout d'une nouvelle native requiert une reconfiguration manuelle + +À mesure que le réseau grossit, les natives deviennent un goulot d'étranglement +analogue aux super-nodes Gnutella 2 ou aux directory servers de BitTorrent. + + +9.2 L'analogie avec les indexeurs : un modèle déjà résolu +---------------------------------------------------------- + +Le problème a déjà été résolu pour les indexers : un nœud démarre avec un pool +de bootstrap (StaticIndexers), puis l'enrichit dynamiquement via les natives +(GetIndexersResponse, scoring, fill rate). Le pool d'indexers est vivant. + +La même logique devrait s'appliquer aux natives : + + ÉTAT ACTUEL : StaticNatives (boot) → léger enrichissement runtime + CIBLE : StaticNatives (boot seulement) → pool natif vivant et ouvert + +Les natives ne seraient plus une liste exhaustive connue de tous, mais un +annuaire distribué auto-organisé, rejoint par bootstrap puis par propagation. +Un nœud démarre avec 1-3 natives connues, et en découvre d'autres par gossip +ou par requête, exactement comme un indexer découvre ses pairs. + + +9.3 Séparation des rôles : toile vs annuaire +-------------------------------------------- + +Cette évolution amène à clarifier deux fonctions que l'architecture actuelle +fait porter simultanément aux natives : + + INDEXERS — La Toile de Routage + ┌─────────────────────────────────────────────────────────────────────────┐ + │ Rôle : Acheminer le trafic, servir les requêtes GET/Publish des nodes │ + │ Modèle : Pool sélectif, scoring qualité, fill rate, rotation dynamique │ + │ Taille : Quelques dizaines par node (pool optimisé, pas exhaustif) │ + │ Analogie : CDN edge nodes, Tor relays │ + └─────────────────────────────────────────────────────────────────────────┘ + + NATIVES — L'Annuaire Distribué + ┌─────────────────────────────────────────────────────────────────────────┐ + │ Rôle : Connaître et diffuser l'existence des indexers disponibles │ + │ Modèle : Plus exhaustif que moins, propagation par gossip ou routage │ + │ Taille : Peut croître sans limite si le routage est O(log N) │ + │ Analogie : DNS racine distribué, Kademlia bootstrap nodes │ + └─────────────────────────────────────────────────────────────────────────┘ + +En séparant ces deux préoccupations, le trafic opérationnel (node ↔ indexer) +est découplé du trafic d'annuaire (indexer ↔ native). Les natives n'absorbent +plus les heartbeats fonctionnels, elles ne font que maintenir un annuaire. + + +9.4 Inspiration Tapestry : routage O(log N) pour le plan d'annuaire +-------------------------------------------------------------------- + +Tapestry [Zhao et al., 2004] démontre que des réseaux de millions de nœuds +sont atteignables avec un routage Plaxton digit-by-digit : + + log₁₆(1 000 000) ≈ 5 sauts + Table de routage ≈ 5 × 16 = ~80 entrées par nœud + État par nœud = O(log N) — constant en pratique + +Appliqué aux natives d'oc-discovery, cela donnerait : + + - Les natives forment entre elles un overlay DHT (Kademlia ou Plaxton) + - Chaque native ne connaît que O(log N) autres natives, pas toutes + - Une requête "trouver des indexers de type X" traverse O(log N) natives + - L'ajout d'une nouvelle native = jointure DHT, aucune reconfig centralisée + +Les indexers actuels deviendraient des feuilles de cet overlay, enregistrées +auprès de leur native de référence (la plus proche dans l'espace de nommage). + + +9.5 Résumé de la trajectoire +----------------------------- + + Phase 1 (actuel) : Natives statiques, pool d'indexers dynamique + Phase 2 (proche) : Pool de natives dynamique par bootstrap + gossip, + même mécanique que le pool d'indexers (déjà résolu) + Phase 3 (long terme): Natives organisées en overlay DHT O(log N), + annuaire décentralisé sans hub autoritaire fixe, + trafic opérationnel (indexers) découplé du plan + d'annuaire (natives) + +Ce chemin n'est pas une rupture architecturale : les primitives libp2p +(Kademlia DHT, GossipSub) sont déjà présentes dans oc-discovery. L'évolution +consiste à étendre leur usage au niveau du plan de contrôle des natives, +plutôt qu'au seul plan de données des records. + + +================================================================================ +10. RÉFÉRENCES +================================================================================ + +[1] Maymounkov, P. & Mazières, D. (2002). "Kademlia: A Peer-to-Peer Information + System Based on the XOR Metric." IPTPS 2002. Springer LNCS 2429, pp. 53-65. + +[2] Stoica, I., Morris, R., Karger, D., Kaashoek, M. F., & Balakrishnan, H. + (2001). "Chord: A Scalable Peer-to-Peer Lookup Service for Internet + Applications." ACM SIGCOMM 2001. doi:10.1145/383059.383071. + +[3] Rowstron, A. & Druschel, P. (2001). "Pastry: Scalable, Decentralized Object + Location and Routing for Large-Scale Peer-to-Peer Systems." Middleware 2001. + Springer LNCS 2218. + +[4] Benet, J. (2014). "IPFS - Content Addressed, Versioned, P2P File System." + arXiv:1407.3561. https://arxiv.org/abs/1407.3561 + +[5] libp2p (2019). libp2p Specifications. Protocol Labs. + https://github.com/libp2p/specs + +[6] Dingledine, R., Mathewson, N., & Syverson, P. (2004). "Tor: The Second- + Generation Onion Router." USENIX Security Symposium 2004, pp. 303-320. + +[7] Das, A., Gupta, I., & Motivala, A. (2002). "SWIM: Scalable Weakly-consistent + Infection-style Process Group Membership Protocol." DSN 2002, pp. 303-312. + +[8] DeCandia, G., et al. (2007). "Dynamo: Amazon's Highly Available Key-value + Store." ACM SOSP 2007. doi:10.1145/1294261.1294281. + +[9] Hunt, P., Konar, M., Junqueira, F. P., & Reed, B. (2010). "ZooKeeper: + Wait-free Coordination for Internet-scale Systems." USENIX ATC 2010. + +[10] Kamvar, S. D., Schlosser, M. T., & Garcia-Molina, H. (2003). "The EigenTrust + Algorithm for Reputation Management in P2P Networks." WWW 2003. + doi:10.1145/775152.775242. + +[11] Androulaki, E., et al. (2018). "Hyperledger Fabric: A Distributed Operating + System for Permissioned Blockchains." EuroSys 2018. doi:10.1145/3190508.3190538. + +[12] Nakamoto, S. (2008). "Bitcoin: A Peer-to-Peer Electronic Cash System." + https://bitcoin.org/bitcoin.pdf + +[13] Ethereum Foundation (2021). "Node Discovery Protocol v5." + https://github.com/ethereum/devp2p/blob/master/discv5/discv5.md + +[14] Wood, G. (2016). "Polkadot: Vision for a Heterogeneous Multi-chain Framework." + https://polkadot.network/PolkaDotPaper.pdf + +[15] Donenfeld, J. A. (2017). "WireGuard: Next Generation Kernel Network Tunnel." + NDSS 2017. doi:10.14722/ndss.2017.23160. + +[16] Demers, A., et al. (1987). "Epidemic Algorithms for Replicated Database + Maintenance." ACM PODC 1987. doi:10.1145/41840.41841. + +[17] Kermarrec, A.-M. & van Steen, M. (2007). "Gossiping in Distributed Systems." + ACM SIGOPS Operating Systems Review 41(5), pp. 2-7. + +[18] Mazieres, D., Kaminsky, M., Kaashoek, M. F., & Witchel, E. (2000). "Separating + Key Management from File System Security." ACM SOSP 1999. (référencé dans + l'introduction du ARCHITECTURE_PAPER.txt d'oc-discovery) + +[19] HashiCorp (2014). Consul Architecture Documentation. + https://developer.hashicorp.com/consul/docs/architecture + +[20] Ritter, J. (2001). "Why Gnutella Can't Scale. No, Really." + Clip2 Distributed Search Solutions. + http://clip2.com/GnutellaProtocol04.pdf + +================================================================================ diff --git a/docs/FUTURE_DHT_ARCHITECTURE.txt b/docs/FUTURE_DHT_ARCHITECTURE.txt new file mode 100644 index 0000000..8ed31d1 --- /dev/null +++ b/docs/FUTURE_DHT_ARCHITECTURE.txt @@ -0,0 +1,362 @@ +================================================================================ + OC-DISCOVERY : ARCHITECTURE CIBLE — RÉSEAU DHT SANS NATIFS + Vision d'évolution long terme, issue d'une analyse comparative +================================================================================ + +Rédigé à partir de l'analyse de l'architecture actuelle et de la discussion +comparative avec Tapestry, Kademlia, EigenTrust et les systèmes de réputation +distribués. + +Référence : DECENTRALIZED_SYSTEMS_COMPARISON.txt §9 + + +================================================================================ +1. MOTIVATION +================================================================================ + +L'architecture actuelle (node → indexer → native indexer) est robuste et bien +adaptée à une phase précoce du réseau. Ses limites à l'échelle sont : + + - Pool de natives statique au démarrage → dépendance à la configuration + - Cache local des natives = point de défaillance unique (perte = pool vide) + - Consensus inter-natives bloquant (~7s) déclenché à chaque bootstrap node + - État O(N indexers) par native → croît linéairement avec le réseau + - Nœuds privilégiés structurellement → SPOFs relatifs + +La cible décrite ici supprime la notion de native indexer en tant que tier +architectural. Le réseau devient plat : indexers et nodes sont des acteurs +de même nature, différenciés uniquement par leur rôle volontaire. + + +================================================================================ +2. PRINCIPES FONDAMENTAUX +================================================================================ + + P1. Aucun nœud n'est structurellement privilégié. + P2. La confiance est un produit du temps et de la vérification, pas d'un arbitre. + P3. Les claims d'un acteur sont vérifiables indépendamment par tout pair. + P4. La réputation émerge du comportement collectif, pas d'un signalement central. + P5. La DHT est une infrastructure neutre — elle stocke des faits, pas des jugements. + P6. La configuration statique n'existe plus au runtime — seulement au bootstrap. + + +================================================================================ +3. RÔLES +================================================================================ + +3.1 Node +-------- +Consommateur du réseau. Démarre, sélectionne un pool d'indexers via DHT, +heartbeat ses indexers, accumule des scores localement. Ne publie rien en +routine. Participe aux challenges de consensus à la demande. + +3.2 Indexer +----------- +Acteur volontaire. S'inscrit dans la DHT à la naissance, maintient son record, +sert le trafic des nodes (heartbeat, Publish, Get). Déclare ses métriques dans +chaque réponse heartbeat. Maintient un score agrégé depuis ses nodes connectés. + + Différence avec l'actuel : l'indexer n'a plus de lien avec une native. + Il est autonome. Son existence dans le réseau est prouvée par son record DHT + et par les nodes qui le contactent directement. + +3.3 Nœud DHT infrastructure (ex-native) +---------------------------------------- +N'importe quel nœud suffisamment stable peut maintenir la DHT sans être un +indexer. C'est une configuration, pas un type architectural : `dht_mode: server`. +Ces nœuds maintiennent les k-buckets Kademlia et stockent les records des +indexers. Ils ne connaissent pas le trafic node↔indexer et ne l'orchestrent pas. + + +================================================================================ +4. BOOTSTRAP D'UN NODE +================================================================================ + +4.1 Entrée dans le réseau +------------------------- +Le node démarre avec 1 à 3 adresses de nœuds DHT connus (bootstrap peers). +Ce sont les seules informations statiques nécessaires. Ces peers n'ont pas de +rôle sémantique — ils servent uniquement à entrer dans l'overlay DHT. + +4.2 Découverte du pool d'indexers +---------------------------------- + + Node → DHT.FindProviders(hash("/opencloud/indexers")) + → reçoit une liste de N candidats avec leurs records + +Sélection du pool initial : + + 1. Filtre latence : ping < seuil → proximité réseau réelle + 2. Filtre fill rate : préférer les indexers moins chargés + 3. Tirage pondéré : probabilité ∝ (1 - fill_rate), courbe w(F) = F×(1-F) + indexer à 20% charge → très probable + indexer à 80% charge → peu probable + 4. Filtre diversité : subnet /24 différent pour chaque entrée du pool + +Aucun consensus nécessaire à cette étape. Le node démarre avec une tolérance +basse (voir §7) — il accepte des indexers imparfaits et les évalue au fil du temps. + + +================================================================================ +5. REGISTRATION D'UN INDEXER DANS LA DHT +================================================================================ + +À la naissance, l'indexer publie son record DHT : + + clé : hash("/opencloud/indexers") ← clé fixe, connue de tous + valeur: { + multiaddr : , + region : , + capacity : , + fill_rate : , ← auto-déclaré, vérifiable + peer_count : , ← auto-déclaré, vérifiable + peers : [hash(nodeID1), ...], ← liste hashée des nodes connectés + born_at : , + sig : , ← non-forgeable (PSK context) + } + +Le record est rafraîchi toutes les ~60s (avant expiration du TTL). +Si l'indexer tombe : TTL expire → disparaît de la DHT automatiquement. + +La peer list est hashée pour la confidentialité mais reste vérifiable : +un challenger peut demander directement à un node s'il est connecté à cet indexer. + + +================================================================================ +6. PROTOCOLE HEARTBEAT — QUESTION ET RÉPONSE +================================================================================ + +Le heartbeat devient bidirectionnel : le node pose des questions, l'indexer +répond avec ses déclarations courantes. + +6.1 Structure +------------- + + Node → Indexer : + { + ts : now, + challenge : + } + + Indexer → Node : + { + ts : now, + fill_rate : 0.42, + peer_count : 87, + cached_score : 0.74, ← score agrégé depuis tous ses nodes connectés + challenge_response : {...} ← si challenge présent dans la requête + } + +Le heartbeat normal (sans challenge) est quasi-identique à l'actuel en poids. +Le cached_score indexer est mis à jour progressivement par les feedbacks reçus. + +6.2 Le cached_score de l'indexer +--------------------------------- +L'indexer agrège les scores que ses nodes connectés lui communiquent +(implicitement via le fait qu'ils restent connectés, ou explicitement lors +d'un consensus). Ce score lui donne une vision de sa propre qualité réseau. + +Un node peut comparer son score local de l'indexer avec le cached_score déclaré. +Une forte divergence est un signal d'alerte. + + Score local node : 0.40 ← cet indexer est médiocre pour moi + Cached score : 0.91 ← il se prétend excellent globalement + → déclenche un challenge de vérification + + +================================================================================ +7. MODÈLE DE CONFIANCE PROGRESSIVE +================================================================================ + +7.1 Cycle de vie d'un node +--------------------------- + + Naissance + → tolérance basse : accepte presque n'importe quel indexer du DHT + → switching cost faible : peu de contexte accumulé + → minScore ≈ 20% (dynamicMinScore existant, conservé) + + Quelques heures + → uptime s'accumule sur chaque indexer connu + → scores se stabilisent + → seuil de remplacement qui monte progressivement + + Long terme (jours) + → pool stable, confiance élevée sur les indexers connus + → switching coûteux mais déclenché sur déception franche + → minScore ≈ 80% (maturité) + +7.2 Modèle sous-jacent : beta distribution implicite +------------------------------------------------------ + + α = succès cumulés (heartbeats OK, probes OK, challenges réussis) + β = échecs cumulés (timeouts, probes échoués, challenges ratés) + + confiance = α / (α + β) + + Nouveau indexer : α=0, β=0 → prior neutre, tolérance basse + Après 10 jours : α élevé → confiance stable, seuil de switch élevé + Déception franche : β monte → confiance chute → switch déclenché + +7.3 Ce que "décevoir" signifie +-------------------------------- + + Heartbeat rate → trop de timeouts → fiabilité en baisse + Bandwidth probe → chute sous déclaré → dégradation ou mensonge + Fill rate réel → supérieur au déclaré → indexer surchargé ou malhonnête + Challenge échoué → peer déclaré absent du réseau → claim invalide + Latence → dérive progressive → qualité réseau dégradée + Cached_score gonflé → divergence forte avec score local → suspicion + + +================================================================================ +8. VÉRIFICATION DES CLAIMS — TROIS COUCHES +================================================================================ + +8.1 Couche 1 : passive (chaque heartbeat, 60s) +----------------------------------------------- +Mesures automatiques, zéro coût supplémentaire. + + - RTT du heartbeat → latence directe + - fill_rate déclaré → tiny payload dans la réponse + - peer_count déclaré → tiny payload + - cached_score indexer → comparé au score local + +8.2 Couche 2 : sampling actif (1 heartbeat sur N) +-------------------------------------------------- +Vérifications périodiques, asynchrones, légères. + + Tous les 5 HB (~5min) : spot-check 1 peer aléatoire (voir §8.4) + Tous les 10 HB (~10min): vérification diversité subnet (lookups DHT légers) + Tous les 15 HB (~15min): bandwidth probe (transfert réel, protocole dédié) + +8.3 Couche 3 : consensus (événementiel) +----------------------------------------- +Déclenché sur : admission d'un nouvel indexer dans le pool, ou suspicion détectée. + + Node sélectionne une claim vérifiable de l'indexer cible X + Node vérifie lui-même + Node demande à ses indexers de confiance : "vérifiez cette claim sur X" + Chaque indexer vérifie indépendamment + Convergence des résultats → X est honnête → admission + Divergence → X est suspect → rejet ou probation + + Le consensus est léger : quelques contacts out-of-band, pas de round bloquant. + Il n'est pas continu — il est événementiel. + +8.4 Vérification out-of-band (pas de DHT writes par les nodes) +---------------------------------------------------------------- +Les nodes ne publient PAS de contact records continus dans la DHT. +Cela éviterait N×M records à rafraîchir (coût DHT élevé à l'échelle). + +À la place, lors d'un challenge : + + Challenger sélectionne 2-3 peers dans la peer list déclarée par X + → contacte ces peers directement : "es-tu connecté à indexer X ?" + → réponse directe (out-of-band, pas via DHT) + → vérification sans écriture DHT + +L'indexer ne peut pas faire répondre "oui" à des peers qui ne lui sont pas +connectés. La vérification est non-falsifiable et sans coût DHT. + +8.5 Pourquoi X ne peut pas tricher +------------------------------------ +X ne peut pas coordonner des réponses différentes vers des challengers +simultanés. Chaque challenger contacte indépendamment les mêmes peers. +Si X ment sur sa peer list : + + - Challenger A contacte peer P → "non, pas connecté à X" + - Challenger B contacte peer P → "non, pas connecté à X" + - Consensus : X ment → score chute chez tous les challengers + - Effet réseau : progressivement, X perd ses connections + - Peer list DHT se vide → claims futures encore moins crédibles + + +================================================================================ +9. EFFET RÉSEAU SANS SIGNALEMENT CENTRAL +================================================================================ + +Un node qui pénalise un indexer n'envoie aucun "rapport" à quiconque. +Ses actions locales produisent l'effet réseau par agrégation : + + Node baisse le score de X → X reçoit moins de trafic de ce node + Node switche vers Y → X perd un client + Node refuse les challenges X → X ne peut plus participer aux consensus + +Si 200 nodes font pareil : + + X perd la majorité de ses connections + Sa peer list DHT se vide (peers contactés directement disent "non") + Son cached_score s'effondre (peu de nodes restent) + Les nouveaux nodes qui voient X dans la DHT obtiennent des challenges échoués + X est naturellement exclu sans aucune décision centrale + +Inversement, un indexer honnête voit ses scores monter sur tous ses nodes +connectés, sa peer list se densifier, ses challenges réussis systématiquement. +Sa réputation est un produit observable et vérifiable. + + +================================================================================ +10. RÉSUMÉ DE L'ARCHITECTURE +================================================================================ + + DHT → annuaire neutre, vérité des records indexers + maintenu par tout nœud stable (dht_mode: server) + + Indexer → acteur volontaire, s'inscrit, maintient ses claims, + sert le trafic, accumule son propre score agrégé + + Node → consommateur, score passif + sampling + consensus léger, + confiance progressive, switching adaptatif + + Heartbeat → métronome 60s + vecteur de déclarations légères + challenge optionnel + + Consensus → événementiel, multi-challengers indépendants, + vérification out-of-band sur claims DHT + + Confiance → beta implicite, progressive, switching cost croissant avec l'âge + + Réputation → émerge du comportement collectif, aucun arbitre central + + Bootstrap → 1-3 peers DHT connus → seule configuration statique nécessaire + + +================================================================================ +11. TRAJECTOIRE DE MIGRATION +================================================================================ + + Phase 1 (actuel) + Natives statiques, pool indexers dynamique, consensus inter-natives + → robuste, adapté à la phase précoce + + Phase 2 (intermédiaire) + Pool de natives dynamique via DHT (bootstrap + gossip) + Même protocole natif, juste la découverte devient dynamique + → supprime la dépendance à la configuration statique des natives + → voir DECENTRALIZED_SYSTEMS_COMPARISON.txt §9.2 + + Phase 3 (cible) + Architecture décrite dans ce document + Natives disparaissent en tant que tier architectural + DHT = infrastructure, indexers = acteurs autonomes + Scoring et consensus entièrement côté node + → aucun nœud privilégié, scalabilité O(log N) + + La migration Phase 2 → Phase 3 est une refonte du plan de contrôle. + Le plan de données (heartbeat node↔indexer, Publish, Get) est inchangé. + Les primitives libp2p (Kademlia DHT, GossipSub) sont déjà présentes. + + +================================================================================ +12. PROPRIÉTÉS DU SYSTÈME CIBLE +================================================================================ + + Scalabilité O(log N) — routage DHT Kademlia + Résilience Pas de SPOF structurel, TTL = seule source de vérité + Confiance Progressive, vérifiable, émergente + Sybil resistance PSK — seuls les nœuds avec la clé peuvent publier + Cold start Tolérance basse initiale, montée progressive (existant) + Honnêteté Claims vérifiables out-of-band, non-falsifiables + Décentralisation Aucun nœud ne connaît l'état global complet + +================================================================================ diff --git a/docs/diagrams/01_node_init.mmd b/docs/diagrams/01_node_init.mmd deleted file mode 100644 index fe917b0..0000000 --- a/docs/diagrams/01_node_init.mmd +++ /dev/null @@ -1,56 +0,0 @@ -sequenceDiagram - title Node Initialization — Pair A (InitNode) - - participant MainA as main (Pair A) - participant NodeA as Node A - participant libp2pA as libp2p (Pair A) - participant DBA as DB Pair A (oc-lib) - participant NATSA as NATS A - participant IndexerA as Indexer (partagé) - participant StreamA as StreamService A - participant PubSubA as PubSubService A - - MainA->>NodeA: InitNode(isNode, isIndexer, isNativeIndexer) - - NodeA->>NodeA: LoadKeyFromFilePrivate() → priv - NodeA->>NodeA: LoadPSKFromFile() → psk - - NodeA->>libp2pA: New(PrivateNetwork(psk), Identity(priv), ListenAddr:4001) - libp2pA-->>NodeA: host A (PeerID_A) - - Note over NodeA: isNode == true - - NodeA->>libp2pA: NewGossipSub(ctx, host) - libp2pA-->>NodeA: ps (GossipSub) - - NodeA->>IndexerA: ConnectToIndexers → SendHeartbeat /opencloud/heartbeat/1.0 - Note over IndexerA: Heartbeat long-lived établi
Score qualité calculé (bw + uptime + diversité) - IndexerA-->>NodeA: OK - - NodeA->>NodeA: claimInfo(name, hostname) - NodeA->>IndexerA: TempStream /opencloud/record/publish/1.0 - NodeA->>IndexerA: json.Encode(PeerRecord A signé) - IndexerA->>IndexerA: DHT.PutValue("/node/"+DID_A, record) - - NodeA->>DBA: NewRequestAdmin(PEER).Search(SELF) - DBA-->>NodeA: peer A local (ou UUID généré) - - NodeA->>NodeA: StartGC(30s) — GC sur StreamRecords - - NodeA->>StreamA: InitStream(ctx, host, PeerID_A, 1000, nodeA) - StreamA->>StreamA: SetStreamHandler(heartbeat/partner, search, planner, ...) - StreamA->>DBA: Search(PEER, PARTNER) → liste partenaires - DBA-->>StreamA: [] (aucun partenaire au démarrage) - StreamA-->>NodeA: StreamService A - - NodeA->>PubSubA: InitPubSub(ctx, host, ps, nodeA, streamA) - PubSubA->>PubSubA: subscribeEvents(PB_SEARCH, timeout=-1) - PubSubA-->>NodeA: PubSubService A - - NodeA->>NodeA: SubscribeToSearch(ps, callback) - Note over NodeA: callback: GetPeerRecord(evt.From)
→ StreamService.SendResponse - - NodeA->>NATSA: ListenNATS(nodeA) - Note over NATSA: Enregistre handlers:
CREATE_RESOURCE, PROPALGATION_EVENT - - NodeA-->>MainA: *Node A prêt diff --git a/docs/diagrams/01_node_init.puml b/docs/diagrams/01_node_init.puml index 2653851..abecbf1 100644 --- a/docs/diagrams/01_node_init.puml +++ b/docs/diagrams/01_node_init.puml @@ -1,10 +1,10 @@ @startuml -title Node Initialization — Pair A (InitNode) +title Node Initialization — Peer A (InitNode) -participant "main (Pair A)" as MainA +participant "main (Peer A)" as MainA participant "Node A" as NodeA -participant "libp2p (Pair A)" as libp2pA -participant "DB Pair A (oc-lib)" as DBA +participant "libp2p (Peer A)" as libp2pA +participant "DB Peer A (oc-lib)" as DBA participant "NATS A" as NATSA participant "Indexer (partagé)" as IndexerA participant "StreamService A" as StreamA @@ -24,35 +24,35 @@ NodeA -> libp2pA: NewGossipSub(ctx, host) libp2pA --> NodeA: ps (GossipSub) NodeA -> IndexerA: ConnectToIndexers → SendHeartbeat /opencloud/heartbeat/1.0 -note over IndexerA: Heartbeat long-lived établi\nScore qualité calculé (bw + uptime + diversité) +note over IndexerA: Heartbeat long-lived established\nQuality Score evaluated (bw + uptime + diversity) IndexerA --> NodeA: OK NodeA -> NodeA: claimInfo(name, hostname) NodeA -> IndexerA: TempStream /opencloud/record/publish/1.0 -NodeA -> IndexerA: json.Encode(PeerRecord A signé) +NodeA -> IndexerA: stream.Encode(PeerRecord A signé) IndexerA -> IndexerA: DHT.PutValue("/node/"+DID_A, record) -NodeA -> DBA: NewRequestAdmin(PEER).Search(SELF) -DBA --> NodeA: peer A local (ou UUID généré) +NodeA -> DBA: DB(PEER).Search(SELF) +DBA --> NodeA: local peer A (or new generated UUID) -NodeA -> NodeA: StartGC(30s) — GC sur StreamRecords +NodeA -> NodeA: StartGC(30s) — GarbageCollector on StreamRecords NodeA -> StreamA: InitStream(ctx, host, PeerID_A, 1000, nodeA) StreamA -> StreamA: SetStreamHandler(heartbeat/partner, search, planner, ...) -StreamA -> DBA: Search(PEER, PARTNER) → liste partenaires -DBA --> StreamA: [] (aucun partenaire au démarrage) +StreamA -> DBA: Search(PEER, PARTNER) → partner list +DBA --> StreamA: Heartbeat long-lived established to partners StreamA --> NodeA: StreamService A NodeA -> PubSubA: InitPubSub(ctx, host, ps, nodeA, streamA) PubSubA -> PubSubA: subscribeEvents(PB_SEARCH, timeout=-1) PubSubA --> NodeA: PubSubService A -NodeA -> NodeA: SubscribeToSearch(ps, callback) +NodeA -> NodeA: SubscribeToSearch(ps, callback) (search global topic for resources) note over NodeA: callback: GetPeerRecord(evt.From)\n→ StreamService.SendResponse NodeA -> NATSA: ListenNATS(nodeA) -note over NATSA: Enregistre handlers:\nCREATE_RESOURCE, PROPALGATION_EVENT +note over NATSA: Subscribes handlers:\nCREATE_RESOURCE, PROPALGATION_EVENT -NodeA --> MainA: *Node A prêt +NodeA --> MainA: *Node A is ready @enduml diff --git a/docs/diagrams/02_node_claim.mmd b/docs/diagrams/02_node_claim.mmd deleted file mode 100644 index 10ad27c..0000000 --- a/docs/diagrams/02_node_claim.mmd +++ /dev/null @@ -1,38 +0,0 @@ -sequenceDiagram - title Node Claim — Pair A publie son PeerRecord (claimInfo + publishPeerRecord) - - participant DBA as DB Pair A (oc-lib) - participant NodeA as Node A - participant IndexerA as Indexer (partagé) - participant DHT as DHT Kademlia - participant NATSA as NATS A - - NodeA->>DBA: NewRequestAdmin(PEER).Search(SELF) - DBA-->>NodeA: existing peer (DID_A) ou nouveau UUID - - NodeA->>NodeA: LoadKeyFromFilePrivate() → priv A - NodeA->>NodeA: LoadKeyFromFilePublic() → pub A - NodeA->>NodeA: crypto.MarshalPublicKey(pub A) → pubBytes - - NodeA->>NodeA: Build PeerRecord A {
Name, DID, PubKey,
PeerID: PeerID_A,
APIUrl: hostname,
StreamAddress: /ip4/.../tcp/4001/p2p/PeerID_A,
NATSAddress, WalletAddress
} - - NodeA->>NodeA: sha256(json(rec)) → hash - NodeA->>NodeA: priv.Sign(hash) → signature - NodeA->>NodeA: rec.ExpiryDate = now + 150s - - loop Pour chaque StaticIndexer (Indexer A, B, …) - NodeA->>IndexerA: TempStream /opencloud/record/publish/1.0 - NodeA->>IndexerA: json.Encode(PeerRecord A signé) - - IndexerA->>IndexerA: Verify signature - IndexerA->>IndexerA: Check heartbeat stream actif pour PeerID_A - IndexerA->>DHT: PutValue("/node/"+DID_A, PeerRecord A) - DHT-->>IndexerA: ok - end - - NodeA->>NodeA: rec.ExtractPeer(DID_A, DID_A, pub A) - NodeA->>NATSA: SetNATSPub(CREATE_RESOURCE, {PEER, Peer A JSON}) - NATSA->>DBA: Upsert Peer A (SearchAttr: peer_id) - DBA-->>NATSA: ok - - NodeA-->>NodeA: *peer.Peer A (SELF) diff --git a/docs/diagrams/02_node_claim.puml b/docs/diagrams/02_node_claim.puml index cfed09d..f0b9181 100644 --- a/docs/diagrams/02_node_claim.puml +++ b/docs/diagrams/02_node_claim.puml @@ -1,31 +1,29 @@ @startuml -title Node Claim — Pair A publie son PeerRecord (claimInfo + publishPeerRecord) +title Node Claim — Peer A publish its PeerRecord (claimInfo + publishPeerRecord) -participant "DB Pair A (oc-lib)" as DBA +participant "DB Peer A (oc-lib)" as DBA participant "Node A" as NodeA -participant "Indexer (partagé)" as IndexerA +participant "Indexer (shared)" as IndexerA participant "DHT Kademlia" as DHT participant "NATS A" as NATSA -NodeA -> DBA: NewRequestAdmin(PEER).Search(SELF) -DBA --> NodeA: existing peer (DID_A) ou nouveau UUID +NodeA -> DBA: DB(PEER).Search(SELF) +DBA --> NodeA: existing peer (DID_A) or new UUID NodeA -> NodeA: LoadKeyFromFilePrivate() → priv A NodeA -> NodeA: LoadKeyFromFilePublic() → pub A -NodeA -> NodeA: crypto.MarshalPublicKey(pub A) → pubBytes NodeA -> NodeA: Build PeerRecord A {\n Name, DID, PubKey,\n PeerID: PeerID_A,\n APIUrl: hostname,\n StreamAddress: /ip4/.../tcp/4001/p2p/PeerID_A,\n NATSAddress, WalletAddress\n} -NodeA -> NodeA: sha256(json(rec)) → hash -NodeA -> NodeA: priv.Sign(hash) → signature +NodeA -> NodeA: priv.Sign(rec) → signature NodeA -> NodeA: rec.ExpiryDate = now + 150s -loop Pour chaque StaticIndexer (Indexer A, B, ...) +loop For every Node Binded Indexer (Indexer A, B, ...) NodeA -> IndexerA: TempStream /opencloud/record/publish/1.0 - NodeA -> IndexerA: json.Encode(PeerRecord A signé) + NodeA -> IndexerA: strea!.Encode(Signed PeerRecord A) IndexerA -> IndexerA: Verify signature - IndexerA -> IndexerA: Check heartbeat stream actif pour PeerID_A + IndexerA -> IndexerA: Check PeerID_A heartbeat stream IndexerA -> DHT: PutValue("/node/"+DID_A, PeerRecord A) DHT --> IndexerA: ok end diff --git a/docs/diagrams/03_indexer_heartbeat.mmd b/docs/diagrams/03_indexer_heartbeat.mmd deleted file mode 100644 index b035280..0000000 --- a/docs/diagrams/03_indexer_heartbeat.mmd +++ /dev/null @@ -1,47 +0,0 @@ -sequenceDiagram - title Indexer — Heartbeat double (Pair A + Pair B → Indexer partagé) - - participant NodeA as Node A - participant NodeB as Node B - participant Indexer as IndexerService (partagé) - - Note over NodeA,NodeB: Chaque pair tick toutes les 20s - - par Pair A heartbeat - NodeA->>Indexer: NewStream /opencloud/heartbeat/1.0 - NodeA->>Indexer: json.Encode(Heartbeat A {Name, DID_A, PeerID_A, IndexersBinded}) - - Indexer->>Indexer: CheckHeartbeat(host, stream, streams, mu, maxNodes) - Note over Indexer: len(peers) < maxNodes ? - - Indexer->>Indexer: getBandwidthChallenge(512–2048 bytes, stream) - Indexer->>NodeA: Write(random payload) - NodeA->>Indexer: Echo(same payload) - Indexer->>Indexer: Mesure round-trip → Mbps A - - Indexer->>Indexer: getDiversityRate(host, IndexersBinded_A) - Note over Indexer: /24 subnet diversity des indexeurs liés - - Indexer->>Indexer: ComputeIndexerScore(uptimeA%, MbpsA%, diversityA%) - Note over Indexer: Score = 0.4×uptime + 0.4×bpms + 0.2×diversity - - alt Score A < 75 - Indexer->>NodeA: (close stream) - else Score A ≥ 75 - Indexer->>Indexer: StreamRecord[PeerID_A] = {DID_A, Heartbeat, UptimeTracker} - end - and Pair B heartbeat - NodeB->>Indexer: NewStream /opencloud/heartbeat/1.0 - NodeB->>Indexer: json.Encode(Heartbeat B {Name, DID_B, PeerID_B, IndexersBinded}) - - Indexer->>Indexer: CheckHeartbeat → getBandwidthChallenge - Indexer->>NodeB: Write(random payload) - NodeB->>Indexer: Echo(same payload) - Indexer->>Indexer: ComputeIndexerScore(uptimeB%, MbpsB%, diversityB%) - - alt Score B ≥ 75 - Indexer->>Indexer: StreamRecord[PeerID_B] = {DID_B, Heartbeat, UptimeTracker} - end - end - - Note over Indexer: Les deux pairs sont désormais
enregistrés avec leurs streams actifs diff --git a/docs/diagrams/03_indexer_heartbeat.puml b/docs/diagrams/03_indexer_heartbeat.puml index 2ecc458..1a7cd3d 100644 --- a/docs/diagrams/03_indexer_heartbeat.puml +++ b/docs/diagrams/03_indexer_heartbeat.puml @@ -1,49 +1,49 @@ -@startuml -title Indexer — Heartbeat double (Pair A + Pair B → Indexer partagé) +@startuml indexer_heartbeat +title Indexer — Heartbeat node → indexer (score on 5 metrics) participant "Node A" as NodeA participant "Node B" as NodeB -participant "IndexerService (partagé)" as Indexer +participant "IndexerService" as Indexer -note over NodeA,NodeB: Chaque pair tick toutes les 20s +note over NodeA,NodeB: Every node tick every 20s (SendHeartbeat) -par Pair A heartbeat +par Node A heartbeat NodeA -> Indexer: NewStream /opencloud/heartbeat/1.0 - NodeA -> Indexer: json.Encode(Heartbeat A {Name, DID_A, PeerID_A, IndexersBinded}) + NodeA -> Indexer: stream.Encode(Heartbeat{Name, PeerID_A, IndexersBinded, Record}) - Indexer -> Indexer: CheckHeartbeat(host, stream, streams, mu, maxNodes) - note over Indexer: len(peers) < maxNodes ? + Indexer -> Indexer: CheckHeartbeat(host, stream, dec, streams, mu, maxNodes) + note over Indexer: len(h.Network().Peers()) >= maxNodes → reject - Indexer -> Indexer: getBandwidthChallenge(512-2048 bytes, stream) - Indexer -> NodeA: Write(random payload) - NodeA -> Indexer: Echo(same payload) - Indexer -> Indexer: Mesure round-trip → Mbps A + Indexer -> Indexer: getBandwidthChallengeRate(host, remotePeer, 512-2048B) - Indexer -> Indexer: getDiversityRate(host, IndexersBinded_A) - note over Indexer: /24 subnet diversity des indexeurs liés + Indexer -> Indexer: getOwnDiversityRate(host)\\nh.Network().Peers() + Peerstore.Addrs()\\n→ ratio /24 subnets distincts - Indexer -> Indexer: ComputeIndexerScore(uptimeA%, MbpsA%, diversityA%) - note over Indexer: Score = 0.4×uptime + 0.4×bpms + 0.2×diversity + Indexer -> Indexer: fillRate = len(h.Network().Peers()) / maxNodes - alt Score A < 75 - Indexer -> NodeA: (close stream) - else Score A >= 75 - Indexer -> Indexer: StreamRecord[PeerID_A] = {DID_A, Heartbeat, UptimeTracker} + Indexer -> Indexer: Retrieve existing UptimeTracker\\noldTracker.RecordHeartbeat()\\n→ TotalOnline += gap si gap ≤ 120s\\nuptimeRatio = TotalOnline / time.Since(FirstSeen) + + Indexer -> Indexer: ComputeIndexerScore(\\n uptimeRatio, bpms, diversity,\\n latencyScore, fillRate\\n)\\nScore = (0.20×U + 0.20×B + 0.20×D + 0.15×L + 0.25×F) × 100 + + Indexer -> Indexer: dynamicMinScore(age)\\n= 20 + 60×(hours/24), max 80 + + alt Score A < dynamicMinScore(age) + Indexer -> NodeA: (close stream — "not enough trusting value") + else Score A >= dynamicMinScore(age) + Indexer -> Indexer: streams[PeerID_A].HeartbeatStream = hb.Stream\\nstreams[PeerID_A].HeartbeatStream.UptimeTracker = oldTracker\\nstreams[PeerID_A].LastScore = hb.Score + note over Indexer: AfterHeartbeat → republish PeerRecord on DHT end -else Pair B heartbeat + +else Node B heartbeat NodeB -> Indexer: NewStream /opencloud/heartbeat/1.0 - NodeB -> Indexer: json.Encode(Heartbeat B {Name, DID_B, PeerID_B, IndexersBinded}) + NodeB -> Indexer: stream.Encode(Heartbeat{Name, PeerID_B, IndexersBinded, Record}) - Indexer -> Indexer: CheckHeartbeat → getBandwidthChallenge - Indexer -> NodeB: Write(random payload) - NodeB -> Indexer: Echo(same payload) - Indexer -> Indexer: ComputeIndexerScore(uptimeB%, MbpsB%, diversityB%) + Indexer -> Indexer: CheckHeartbeat → getBandwidthChallengeRate\\n→ getOwnDiversityRate → ComputeIndexerScore(5 composants) - alt Score B >= 75 - Indexer -> Indexer: StreamRecord[PeerID_B] = {DID_B, Heartbeat, UptimeTracker} + alt Score B >= dynamicMinScore(age) + Indexer -> Indexer: streams[PeerID_B] subscribed + LastScore updated end end par -note over Indexer: Les deux pairs sont désormais\nenregistrés avec leurs streams actifs +note over Indexer: GC ticker 30s — gc()\\nnow.After(Expiry) où Expiry = lastHBTime + 2min\\n→ AfterDelete(pid, name, did) @enduml diff --git a/docs/diagrams/04_indexer_publish.mmd b/docs/diagrams/04_indexer_publish.mmd deleted file mode 100644 index e708a24..0000000 --- a/docs/diagrams/04_indexer_publish.mmd +++ /dev/null @@ -1,41 +0,0 @@ -sequenceDiagram - title Indexer — Pair A publie, Pair B publie (handleNodePublish → DHT) - - participant NodeA as Node A - participant NodeB as Node B - participant Indexer as IndexerService (partagé) - participant DHT as DHT Kademlia - - Note over NodeA: Après claimInfo ou refresh TTL - - par Pair A publie son PeerRecord - NodeA->>Indexer: TempStream /opencloud/record/publish/1.0 - NodeA->>Indexer: json.Encode(PeerRecord A {DID_A, PeerID_A, PubKey_A, Expiry, Sig_A}) - - Indexer->>Indexer: Verify sig_A (reconstruit rec minimal, pubKey_A.Verify) - Indexer->>Indexer: Check StreamRecords[Heartbeat][PeerID_A] existe - - alt Heartbeat actif pour A - Indexer->>Indexer: StreamRecord A → DID_A, Record=PeerRecord A, LastSeen=now - Indexer->>DHT: PutValue("/node/"+DID_A, PeerRecord A JSON) - DHT-->>Indexer: ok - else Pas de heartbeat - Indexer->>NodeA: (erreur "no heartbeat", stream close) - end - and Pair B publie son PeerRecord - NodeB->>Indexer: TempStream /opencloud/record/publish/1.0 - NodeB->>Indexer: json.Encode(PeerRecord B {DID_B, PeerID_B, PubKey_B, Expiry, Sig_B}) - - Indexer->>Indexer: Verify sig_B - Indexer->>Indexer: Check StreamRecords[Heartbeat][PeerID_B] existe - - alt Heartbeat actif pour B - Indexer->>Indexer: StreamRecord B → DID_B, Record=PeerRecord B, LastSeen=now - Indexer->>DHT: PutValue("/node/"+DID_B, PeerRecord B JSON) - DHT-->>Indexer: ok - else Pas de heartbeat - Indexer->>NodeB: (erreur "no heartbeat", stream close) - end - end - - Note over DHT: DHT contient maintenant
"/node/DID_A" et "/node/DID_B" diff --git a/docs/diagrams/04_indexer_publish.puml b/docs/diagrams/04_indexer_publish.puml index a0aeecc..f81f153 100644 --- a/docs/diagrams/04_indexer_publish.puml +++ b/docs/diagrams/04_indexer_publish.puml @@ -1,43 +1,47 @@ @startuml -title Indexer — Pair A publie, Pair B publie (handleNodePublish → DHT) +title Indexer — Peer A publishing, Peer B publishing (handleNodePublish → DHT) participant "Node A" as NodeA participant "Node B" as NodeB -participant "IndexerService (partagé)" as Indexer +participant "IndexerService (shared)" as Indexer participant "DHT Kademlia" as DHT -note over NodeA: Après claimInfo ou refresh TTL +note over NodeA: Start after claimInfo or refresh TTL -par Pair A publie son PeerRecord +par Peer A publish its PeerRecord NodeA -> Indexer: TempStream /opencloud/record/publish/1.0 - NodeA -> Indexer: json.Encode(PeerRecord A {DID_A, PeerID_A, PubKey_A, Expiry, Sig_A}) + NodeA -> Indexer: stream.Encode(PeerRecord A {DID_A, PeerID_A, PubKey_A, Expiry, Sig_A}) Indexer -> Indexer: Verify sig_A (reconstruit rec minimal, pubKey_A.Verify) Indexer -> Indexer: Check StreamRecords[Heartbeat][PeerID_A] existe - alt Heartbeat actif pour A + alt A active Heartbeat Indexer -> Indexer: StreamRecord A → DID_A, Record=PeerRecord A, LastSeen=now Indexer -> DHT: PutValue("/node/"+DID_A, PeerRecord A JSON) + Indexer -> DHT: PutValue("/name/"+name_A, DID_A) + Indexer -> DHT: PutValue("/peer/"+peer_id_A, DID_A) DHT --> Indexer: ok else Pas de heartbeat Indexer -> NodeA: (erreur "no heartbeat", stream close) end -else Pair B publie son PeerRecord +else Peer B publish its PeerRecord NodeB -> Indexer: TempStream /opencloud/record/publish/1.0 - NodeB -> Indexer: json.Encode(PeerRecord B {DID_B, PeerID_B, PubKey_B, Expiry, Sig_B}) + NodeB -> Indexer: stream.Encode(PeerRecord B {DID_B, PeerID_B, PubKey_B, Expiry, Sig_B}) Indexer -> Indexer: Verify sig_B Indexer -> Indexer: Check StreamRecords[Heartbeat][PeerID_B] existe - alt Heartbeat actif pour B + alt B Active Heartbeat Indexer -> Indexer: StreamRecord B → DID_B, Record=PeerRecord B, LastSeen=now Indexer -> DHT: PutValue("/node/"+DID_B, PeerRecord B JSON) + Indexer -> DHT: PutValue("/name/"+name_B, DID_B) + Indexer -> DHT: PutValue("/peer/"+peer_id_B, DID_B) DHT --> Indexer: ok else Pas de heartbeat Indexer -> NodeB: (erreur "no heartbeat", stream close) end end par -note over DHT: DHT contient maintenant\n"/node/DID_A" et "/node/DID_B" +note over DHT: DHT got \n"/node/DID_A" et "/node/DID_B" @enduml diff --git a/docs/diagrams/05_indexer_get.mmd b/docs/diagrams/05_indexer_get.mmd deleted file mode 100644 index 2714294..0000000 --- a/docs/diagrams/05_indexer_get.mmd +++ /dev/null @@ -1,49 +0,0 @@ -sequenceDiagram - title Indexer — Pair A résout Pair B (GetPeerRecord + handleNodeGet) - - participant NATSA as NATS A - participant DBA as DB Pair A (oc-lib) - participant NodeA as Node A - participant Indexer as IndexerService (partagé) - participant DHT as DHT Kademlia - participant NATSA2 as NATS A (retour) - - Note over NodeA: Déclenché par : NATS PB_SEARCH PEER
ou callback SubscribeToSearch - - NodeA->>DBA: NewRequestAdmin(PEER).Search(DID_B ou PeerID_B) - DBA-->>NodeA: Peer B local (si connu) → résout DID_B + PeerID_B
sinon utilise la valeur brute - - loop Pour chaque StaticIndexer - NodeA->>Indexer: TempStream /opencloud/record/get/1.0 - NodeA->>Indexer: json.Encode(GetValue{Key: DID_B, PeerID: PeerID_B}) - - Indexer->>Indexer: key = "/node/" + DID_B - Indexer->>DHT: SearchValue(ctx 10s, "/node/"+DID_B) - DHT-->>Indexer: channel de bytes (PeerRecord B) - - loop Pour chaque résultat DHT - Indexer->>Indexer: Unmarshal → PeerRecord B - alt PeerRecord.PeerID == PeerID_B - Indexer->>Indexer: resp.Found=true, resp.Records[PeerID_B]=PeerRecord B - Indexer->>Indexer: StreamRecord B.LastSeen = now (si heartbeat actif) - end - end - - Indexer->>NodeA: json.Encode(GetResponse{Found:true, Records:{PeerID_B: PeerRecord B}}) - end - - loop Pour chaque PeerRecord retourné - NodeA->>NodeA: rec.Verify() → valide signature de B - NodeA->>NodeA: rec.ExtractPeer(ourDID_A, DID_B, pubKey_B) - - alt ourDID_A == DID_B (c'est notre propre entrée) - Note over NodeA: Republier pour rafraîchir le TTL - NodeA->>Indexer: publishPeerRecord(rec) [refresh 2 min] - end - - NodeA->>NATSA2: SetNATSPub(CREATE_RESOURCE, {PEER, Peer B JSON,
SearchAttr:"peer_id"}) - NATSA2->>DBA: Upsert Peer B dans DB A - DBA-->>NATSA2: ok - end - - NodeA-->>NodeA: []*peer.Peer → [Peer B] diff --git a/docs/diagrams/05_indexer_get.puml b/docs/diagrams/05_indexer_get.puml index f17f9c1..131a0a8 100644 --- a/docs/diagrams/05_indexer_get.puml +++ b/docs/diagrams/05_indexer_get.puml @@ -1,5 +1,5 @@ @startuml -title Indexer — Pair A résout Pair B (GetPeerRecord + handleNodeGet) +title Indexer — Peer A discover Peer B (GetPeerRecord + handleNodeGet) participant "NATS A" as NATSA participant "DB Pair A (oc-lib)" as DBA @@ -8,41 +8,41 @@ participant "IndexerService (partagé)" as Indexer participant "DHT Kademlia" as DHT participant "NATS A (retour)" as NATSA2 -note over NodeA: Déclenché par : NATS PB_SEARCH PEER\nou callback SubscribeToSearch +note over NodeA: Trigger : NATS PB_SEARCH PEER\nor callback SubscribeToSearch -NodeA -> DBA: NewRequestAdmin(PEER).Search(DID_B ou PeerID_B) -DBA --> NodeA: Peer B local (si connu) → résout DID_B + PeerID_B\nsinon utilise la valeur brute +NodeA -> DBA: (PEER).Search(DID_B or PeerID_B) +DBA --> NodeA: Local Peer B (if known) → solve DID_B + PeerID_B\nor use search value -loop Pour chaque StaticIndexer - NodeA -> Indexer: TempStream /opencloud/record/get/1.0 - NodeA -> Indexer: json.Encode(GetValue{Key: DID_B, PeerID: PeerID_B}) +loop For every Peer A Binded Indexer + NodeA -> Indexer: TempStream /opencloud/record/get/1.0 -> streamAI + NodeA -> Indexer: streamAI.Encode(GetValue{Key: DID_B, PeerID: PeerID_B}) Indexer -> Indexer: key = "/node/" + DID_B Indexer -> DHT: SearchValue(ctx 10s, "/node/"+DID_B) DHT --> Indexer: channel de bytes (PeerRecord B) - loop Pour chaque résultat DHT - Indexer -> Indexer: Unmarshal → PeerRecord B + loop Pour every results in DHT + Indexer -> Indexer: read → PeerRecord B alt PeerRecord.PeerID == PeerID_B Indexer -> Indexer: resp.Found=true, resp.Records[PeerID_B]=PeerRecord B - Indexer -> Indexer: StreamRecord B.LastSeen = now (si heartbeat actif) + Indexer -> Indexer: StreamRecord B.LastSeen = now (if active heartbeat) end end - Indexer -> NodeA: json.Encode(GetResponse{Found:true, Records:{PeerID_B: PeerRecord B}}) + Indexer -> NodeA: streamAI.Encode(GetResponse{Found:true, Records:{PeerID_B: PeerRecord B}}) end -loop Pour chaque PeerRecord retourné - NodeA -> NodeA: rec.Verify() → valide signature de B +loop For every PeerRecord founded + NodeA -> NodeA: rec.Verify() → valid B signature NodeA -> NodeA: rec.ExtractPeer(ourDID_A, DID_B, pubKey_B) - alt ourDID_A == DID_B (c'est notre propre entrée) - note over NodeA: Republier pour rafraîchir le TTL + alt ourDID_A == DID_B (it's our proper entry) + note over NodeA: Republish to refresh TTL NodeA -> Indexer: publishPeerRecord(rec) [refresh 2 min] end NodeA -> NATSA2: SetNATSPub(CREATE_RESOURCE, {PEER, Peer B JSON,\nSearchAttr:"peer_id"}) - NATSA2 -> DBA: Upsert Peer B dans DB A + NATSA2 -> DBA: Upsert Peer B in DB A DBA --> NATSA2: ok end diff --git a/docs/diagrams/06_native_registration.mmd b/docs/diagrams/06_native_registration.mmd deleted file mode 100644 index b1a5114..0000000 --- a/docs/diagrams/06_native_registration.mmd +++ /dev/null @@ -1,39 +0,0 @@ -sequenceDiagram - title Native Indexer — Enregistrement d'un Indexer auprès du Native - - participant IndexerA as Indexer A - participant IndexerB as Indexer B - participant Native as Native Indexer (partagé) - participant DHT as DHT Kademlia - participant PubSub as GossipSub (oc-indexer-registry) - - Note over IndexerA,IndexerB: Au démarrage + toutes les 60s (StartNativeRegistration) - - par Indexer A s'enregistre - IndexerA->>IndexerA: Build IndexerRegistration{PeerID_A, Addr_A} - IndexerA->>Native: NewStream /opencloud/native/subscribe/1.0 - IndexerA->>Native: json.Encode(IndexerRegistration A) - - Native->>Native: Decode → liveIndexerEntry{PeerID_A, Addr_A, ExpiresAt=now+66s} - Native->>DHT: PutValue("/indexer/"+PeerID_A, entry A) - DHT-->>Native: ok - Native->>Native: liveIndexers[PeerID_A] = entry A - Native->>Native: knownPeerIDs[PeerID_A] = {} - - Native->>PubSub: topic.Publish([]byte(PeerID_A)) - Note over PubSub: Gossipé aux autres Natives
→ ils ajoutent PeerID_A à knownPeerIDs
→ refresh DHT au prochain tick 30s - IndexerA->>Native: stream.Close() - and Indexer B s'enregistre - IndexerB->>IndexerB: Build IndexerRegistration{PeerID_B, Addr_B} - IndexerB->>Native: NewStream /opencloud/native/subscribe/1.0 - IndexerB->>Native: json.Encode(IndexerRegistration B) - - Native->>Native: Decode → liveIndexerEntry{PeerID_B, Addr_B, ExpiresAt=now+66s} - Native->>DHT: PutValue("/indexer/"+PeerID_B, entry B) - DHT-->>Native: ok - Native->>Native: liveIndexers[PeerID_B] = entry B - Native->>PubSub: topic.Publish([]byte(PeerID_B)) - IndexerB->>Native: stream.Close() - end - - Note over Native: liveIndexers = {PeerID_A: entryA, PeerID_B: entryB} diff --git a/docs/diagrams/06_native_registration.puml b/docs/diagrams/06_native_registration.puml index f28399f..c743621 100644 --- a/docs/diagrams/06_native_registration.puml +++ b/docs/diagrams/06_native_registration.puml @@ -1,41 +1,49 @@ -@startuml -title Native Indexer — Enregistrement d'un Indexer auprès du Native +@startuml native_registration +title Native Indexer — Indexer Subscription (StartNativeRegistration) participant "Indexer A" as IndexerA participant "Indexer B" as IndexerB -participant "Native Indexer (partagé)" as Native +participant "Native Indexer" as Native participant "DHT Kademlia" as DHT participant "GossipSub (oc-indexer-registry)" as PubSub -note over IndexerA,IndexerB: Au démarrage + toutes les 60s (StartNativeRegistration) +note over IndexerA,IndexerB: At start + every 60s (RecommendedHeartbeatInterval)\\nStartNativeRegistration → RegisterWithNative + +par Indexer A subscribe + IndexerA -> IndexerA: fillRateFn()\\n= len(StreamRecords[HB]) / maxNodes + + IndexerA -> IndexerA: Build IndexerRegistration{\\n PeerID_A, Addr_A,\\n Timestamp=now.UnixNano(),\\n FillRate=fillRateFn(),\\n PubKey, Signature\\n}\\nreg.Sign(h) -par Indexer A s'enregistre - IndexerA -> IndexerA: Build IndexerRegistration{PeerID_A, Addr_A} IndexerA -> Native: NewStream /opencloud/native/subscribe/1.0 - IndexerA -> Native: json.Encode(IndexerRegistration A) + IndexerA -> Native: stream.Encode(IndexerRegistration A) + + Native -> Native: reg.Verify() — verify signature + Native -> Native: liveIndexerEntry{\\n PeerID_A, Addr_A,\\n ExpiresAt = now + IndexerTTL (90s),\\n FillRate = reg.FillRate,\\n PubKey, Signature\\n} + Native -> Native: liveIndexers[PeerID_A] = entry A + Native -> Native: knownPeerIDs[PeerID_A] = Addr_A - Native -> Native: Decode → liveIndexerEntry{PeerID_A, Addr_A, ExpiresAt=now+66s} Native -> DHT: PutValue("/indexer/"+PeerID_A, entry A) DHT --> Native: ok - Native -> Native: liveIndexers[PeerID_A] = entry A - Native -> Native: knownPeerIDs[PeerID_A] = {} Native -> PubSub: topic.Publish([]byte(PeerID_A)) - note over PubSub: Gossipé aux autres Natives\n→ ils ajoutent PeerID_A à knownPeerIDs\n→ refresh DHT au prochain tick 30s - IndexerA -> Native: stream.Close() -else Indexer B s'enregistre - IndexerB -> IndexerB: Build IndexerRegistration{PeerID_B, Addr_B} - IndexerB -> Native: NewStream /opencloud/native/subscribe/1.0 - IndexerB -> Native: json.Encode(IndexerRegistration B) + note over PubSub: Gossip to other Natives\\n→ it adds PeerID_A to knownPeerIDs\\n→ refresh DHT next tick (30s) - Native -> Native: Decode → liveIndexerEntry{PeerID_B, Addr_B, ExpiresAt=now+66s} - Native -> DHT: PutValue("/indexer/"+PeerID_B, entry B) - DHT --> Native: ok + IndexerA -> Native: stream.Close() + +else Indexer B subscribe + IndexerB -> IndexerB: fillRateFn() + reg.Sign(h) + IndexerB -> Native: NewStream /opencloud/native/subscribe/1.0 + IndexerB -> Native: stream.Encode(IndexerRegistration B) + + Native -> Native: reg.Verify() + liveIndexerEntry{FillRate=reg.FillRate, ExpiresAt=now+90s} Native -> Native: liveIndexers[PeerID_B] = entry B + Native -> DHT: PutValue("/indexer/"+PeerID_B, entry B) Native -> PubSub: topic.Publish([]byte(PeerID_B)) IndexerB -> Native: stream.Close() end par -note over Native: liveIndexers = {PeerID_A: entryA, PeerID_B: entryB} +note over Native: liveIndexers = {PeerID_A: {FillRate:0.3}, PeerID_B: {FillRate:0.6}}\\nTTL 90s — IndexerTTL + +note over Native: Explicit unsubcrive on stop :\\nUnregisterFromNative → /opencloud/native/unsubscribe/1.0\\nNative close all now. @enduml diff --git a/docs/diagrams/07_native_get_consensus.mmd b/docs/diagrams/07_native_get_consensus.mmd deleted file mode 100644 index a04f59f..0000000 --- a/docs/diagrams/07_native_get_consensus.mmd +++ /dev/null @@ -1,60 +0,0 @@ -sequenceDiagram - title Native — ConnectToNatives + Consensus (Pair A bootstrap) - - participant NodeA as Node A - participant Native1 as Native #1 (primary) - participant Native2 as Native #2 - participant NativeN as Native #N - participant DHT as DHT Kademlia - - Note over NodeA: NativeIndexerAddresses configuré
Appelé pendant InitNode → ConnectToIndexers - - NodeA->>NodeA: Parse NativeIndexerAddresses → StaticNatives - NodeA->>Native1: SendHeartbeat /opencloud/heartbeat/1.0 (20s tick) - NodeA->>Native2: SendHeartbeat /opencloud/heartbeat/1.0 (20s tick) - - %% Étape 1 : récupérer un pool initial - NodeA->>Native1: Connect + NewStream /opencloud/native/indexers/1.0 - NodeA->>Native1: json.Encode(GetIndexersRequest{Count: maxIndexer}) - - Native1->>Native1: reachableLiveIndexers() - Note over Native1: Filtre liveIndexers par TTL
ping chaque candidat (PeerIsAlive) - - alt Aucun indexer connu par Native1 - Native1->>Native1: selfDelegate(NodeA.PeerID, resp) - Note over Native1: IsSelfFallback=true
Indexers=[native1 addr] - Native1->>NodeA: GetIndexersResponse{IsSelfFallback:true, Indexers:[native1]} - NodeA->>NodeA: StaticIndexers[native1] = native1 - Note over NodeA: Pas de consensus — native1 utilisé directement comme indexeur - else Indexers disponibles - Native1->>NodeA: GetIndexersResponse{Indexers:[Addr_IndexerA, Addr_IndexerB, ...]} - - %% Étape 2 : consensus - Note over NodeA: clientSideConsensus(candidates) - - par Requêtes consensus parallèles - NodeA->>Native1: NewStream /opencloud/native/consensus/1.0 - NodeA->>Native1: ConsensusRequest{Candidates:[Addr_A, Addr_B]} - Native1->>Native1: Croiser avec liveIndexers propres - Native1->>NodeA: ConsensusResponse{Trusted:[Addr_A, Addr_B], Suggestions:[]} - and - NodeA->>Native2: NewStream /opencloud/native/consensus/1.0 - NodeA->>Native2: ConsensusRequest{Candidates:[Addr_A, Addr_B]} - Native2->>Native2: Croiser avec liveIndexers propres - Native2->>NodeA: ConsensusResponse{Trusted:[Addr_A], Suggestions:[Addr_C]} - and - NodeA->>NativeN: NewStream /opencloud/native/consensus/1.0 - NodeA->>NativeN: ConsensusRequest{Candidates:[Addr_A, Addr_B]} - NativeN->>NativeN: Croiser avec liveIndexers propres - NativeN->>NodeA: ConsensusResponse{Trusted:[Addr_A, Addr_B], Suggestions:[]} - end - - Note over NodeA: Aggrège les votes (timeout 4s)
Addr_A → 3/3 votes → confirmé ✓
Addr_B → 2/3 votes → confirmé ✓ - - alt confirmed < maxIndexer && suggestions disponibles - Note over NodeA: Round 2 — rechallenge avec suggestions - NodeA->>NodeA: clientSideConsensus(confirmed + sample(suggestions)) - end - - NodeA->>NodeA: StaticIndexers = adresses confirmées à majorité - end diff --git a/docs/diagrams/07_native_get_consensus.puml b/docs/diagrams/07_native_get_consensus.puml index 54618b0..fed7ba0 100644 --- a/docs/diagrams/07_native_get_consensus.puml +++ b/docs/diagrams/07_native_get_consensus.puml @@ -1,62 +1,70 @@ -@startuml -title Native — ConnectToNatives + Consensus (Pair A bootstrap) +@startuml native_get_consensus +title Native — ConnectToNatives : fetch pool + Phase 1 + Phase 2 -participant "Node A" as NodeA -participant "Native #1 (primary)" as Native1 -participant "Native #2" as Native2 -participant "Native #N" as NativeN -participant "DHT Kademlia" as DHT +participant "Node / Indexer\\n(appelant)" as Caller +participant "Native A" as NA +participant "Native B" as NB +participant "Indexer A\\n(stable voter)" as IA -note over NodeA: NativeIndexerAddresses configuré\nAppelé pendant InitNode → ConnectToIndexers +note over Caller: NativeIndexerAddresses configured\\nConnectToNatives() called from ConnectToIndexers -NodeA -> NodeA: Parse NativeIndexerAddresses → StaticNatives -NodeA -> Native1: SendHeartbeat /opencloud/heartbeat/1.0 (20s tick) -NodeA -> Native2: SendHeartbeat /opencloud/heartbeat/1.0 (20s tick) +== Step 1 : heartbeat to the native mesh (nativeHeartbeatOnce) == +Caller -> NA: SendHeartbeat /opencloud/heartbeat/1.0 +Caller -> NB: SendHeartbeat /opencloud/heartbeat/1.0 -' Étape 1 : récupérer un pool initial -NodeA -> Native1: Connect + NewStream /opencloud/native/indexers/1.0 -NodeA -> Native1: json.Encode(GetIndexersRequest{Count: maxIndexer}) +== Step 2 : parrallel fetch pool (timeout 6s) == +par fetchIndexersFromNative — parallel + Caller -> NA: NewStream /opencloud/native/indexers/1.0\\nGetIndexersRequest{Count: maxIndexer, From: PeerID} + NA -> NA: reachableLiveIndexers()\\ntri par w(F) = fillRate×(1−fillRate) desc + NA --> Caller: GetIndexersResponse{Indexers:[IA,IB], FillRates:{IA:0.3,IB:0.6}} +else + Caller -> NB: NewStream /opencloud/native/indexers/1.0 + NB -> NB: reachableLiveIndexers() + NB --> Caller: GetIndexersResponse{Indexers:[IA,IB], FillRates:{IA:0.3,IB:0.6}} +end par -Native1 -> Native1: reachableLiveIndexers() -note over Native1: Filtre liveIndexers par TTL\nping chaque candidat (PeerIsAlive) +note over Caller: Fusion → candidates=[IA,IB]\\nisFallback=false -alt Aucun indexer connu par Native1 - Native1 -> Native1: selfDelegate(NodeA.PeerID, resp) - note over Native1: IsSelfFallback=true\nIndexers=[native1 addr] - Native1 -> NodeA: GetIndexersResponse{IsSelfFallback:true, Indexers:[native1]} - NodeA -> NodeA: StaticIndexers[native1] = native1 - note over NodeA: Pas de consensus — native1 utilisé directement comme indexeur -else Indexers disponibles - Native1 -> NodeA: GetIndexersResponse{Indexers:[Addr_IndexerA, Addr_IndexerB, ...]} - - ' Étape 2 : consensus - note over NodeA: clientSideConsensus(candidates) - - par Requêtes consensus parallèles - NodeA -> Native1: NewStream /opencloud/native/consensus/1.0 - NodeA -> Native1: ConsensusRequest{Candidates:[Addr_A, Addr_B]} - Native1 -> Native1: Croiser avec liveIndexers propres - Native1 -> NodeA: ConsensusResponse{Trusted:[Addr_A, Addr_B], Suggestions:[]} +alt isFallback=true (native give themself as Fallback indexer) + note over Caller: resolvePool : avoid consensus\\nadmittedAt = Now (zero)\\nStaticIndexers = {native_addr} +else isFallback=false → Phase 1 + Phase 2 + == Phase 1 — clientSideConsensus (timeout 3s/natif, 4s total) == + par Parralel Consensus + Caller -> NA: NewStream /opencloud/native/consensus/1.0\\nConsensusRequest{Candidates:[IA,IB]} + NA -> NA: compare with clean liveIndexers + NA --> Caller: ConsensusResponse{Trusted:[IA,IB], Suggestions:[]} else - NodeA -> Native2: NewStream /opencloud/native/consensus/1.0 - NodeA -> Native2: ConsensusRequest{Candidates:[Addr_A, Addr_B]} - Native2 -> Native2: Croiser avec liveIndexers propres - Native2 -> NodeA: ConsensusResponse{Trusted:[Addr_A], Suggestions:[Addr_C]} - else - NodeA -> NativeN: NewStream /opencloud/native/consensus/1.0 - NodeA -> NativeN: ConsensusRequest{Candidates:[Addr_A, Addr_B]} - NativeN -> NativeN: Croiser avec liveIndexers propres - NativeN -> NodeA: ConsensusResponse{Trusted:[Addr_A, Addr_B], Suggestions:[]} + Caller -> NB: NewStream /opencloud/native/consensus/1.0 + NB --> Caller: ConsensusResponse{Trusted:[IA], Suggestions:[IC]} end par - note over NodeA: Aggrège les votes (timeout 4s)\nAddr_A → 3/3 votes → confirmé ✓\nAddr_B → 2/3 votes → confirmé ✓ + note over Caller: IA → 2/2 votes → confirmed ✓\\nIB → 1/2 vote → refusé ✗\\nIC → suggestion → round 2 if confirmed < maxIndexer - alt confirmed < maxIndexer && suggestions disponibles - note over NodeA: Round 2 — rechallenge avec suggestions - NodeA -> NodeA: clientSideConsensus(confirmed + sample(suggestions)) + alt confirmed < maxIndexer && available suggestions + note over Caller: Round 2 — rechallenge with confirmed + sample(suggestions)\\nclientSideConsensus([IA, IC]) end - NodeA -> NodeA: StaticIndexers = adresses confirmées à majorité + note over Caller: admittedAt = time.Now() + + == Phase 2 — indexerLivenessVote (timeout 3s/votant, 4s total) == + note over Caller: Search for stable voters in Subscribed Indexers\\nAdmittedAt != zero && age >= MinStableAge (2min) + + alt Stable Voters are available + par Phase 2 parrallel + Caller -> IA: NewStream /opencloud/indexer/consensus/1.0\\nIndexerConsensusRequest{Candidates:[IA]} + IA -> IA: StreamRecords[ProtocolHB][candidate]\\ntime.Since(LastSeen) <= 120s && LastScore >= 30.0 + IA --> Caller: IndexerConsensusResponse{Alive:[IA]} + end par + note over Caller: alive IA confirmed per quorum > 0.5\\npool = {IA} + else No voters are stable (startup) + note over Caller: Phase 1 keep directly\\n(no indexer reaches MinStableAge) + end + + == Replacement pool == + Caller -> Caller: replaceStaticIndexers(pool, admittedAt)\\nStaticIndexerMeta[IA].AdmittedAt = admittedAt end +== Étape 3 : heartbeat to indexers pool (ConnectToIndexers) == +Caller -> Caller: SendHeartbeat /opencloud/heartbeat/1.0\\nvers StaticIndexers + @enduml diff --git a/docs/diagrams/08_nats_create_resource.mmd b/docs/diagrams/08_nats_create_resource.mmd deleted file mode 100644 index 3270ea8..0000000 --- a/docs/diagrams/08_nats_create_resource.mmd +++ /dev/null @@ -1,49 +0,0 @@ -sequenceDiagram - title NATS — CREATE_RESOURCE : Pair A découvre Pair B et établit le stream - - participant AppA as App Pair A (oc-api) - participant NATSA as NATS A - participant NodeA as Node A - participant StreamA as StreamService A - participant NodeB as Node B - participant StreamB as StreamService B - participant DBA as DB Pair A (oc-lib) - - Note over AppA: Pair B vient d'être découvert
(via indexeur ou manuel) - - AppA->>NATSA: Publish(CREATE_RESOURCE, {
FromApp:"oc-api",
Datatype:PEER,
Payload: Peer B {StreamAddress_B, Relation:PARTNER}
}) - - NATSA->>NodeA: ListenNATS callback → CREATE_RESOURCE - - NodeA->>NodeA: resp.FromApp == "oc-discovery" ? → Non, continuer - NodeA->>NodeA: json.Unmarshal(payload) → peer.Peer B - NodeA->>NodeA: pp.AddrInfoFromString(B.StreamAddress) - Note over NodeA: ad_B = {ID: PeerID_B, Addrs: [...]} - - NodeA->>StreamA: Mu.Lock() - - alt peer B.Relation == PARTNER - NodeA->>StreamA: ConnectToPartner(B.StreamAddress) - StreamA->>StreamA: AddrInfoFromString(B.StreamAddress) → ad_B - StreamA->>NodeB: Connect (libp2p) - StreamA->>NodeB: NewStream /opencloud/resource/heartbeat/partner/1.0 - StreamA->>NodeB: json.Encode(Heartbeat{Name_A, DID_A, PeerID_A}) - - NodeB->>StreamB: HandlePartnerHeartbeat(stream) - StreamB->>StreamB: CheckHeartbeat → bandwidth challenge - StreamB->>StreamA: Echo(payload) - StreamB->>StreamB: streams[ProtocolHeartbeatPartner][PeerID_A] = {DID_A, Expiry=now+10s} - - StreamA->>StreamA: streams[ProtocolHeartbeatPartner][PeerID_B] = {DID_B, Expiry=now+10s} - Note over StreamA,StreamB: Stream partner long-lived établi
dans les deux sens - - else peer B.Relation != PARTNER (révocation / blacklist) - Note over NodeA: Supprimer tous les streams vers Pair B - loop Pour chaque protocole dans Streams - NodeA->>StreamA: streams[proto][PeerID_B].Stream.Close() - NodeA->>StreamA: delete(streams[proto], PeerID_B) - end - end - - NodeA->>StreamA: Mu.Unlock() - NodeA->>DBA: (pas de write direct ici — géré par l'app source) diff --git a/docs/diagrams/08_nats_create_resource.puml b/docs/diagrams/08_nats_create_update_peer.puml similarity index 59% rename from docs/diagrams/08_nats_create_resource.puml rename to docs/diagrams/08_nats_create_update_peer.puml index ff5ef25..a750784 100644 --- a/docs/diagrams/08_nats_create_resource.puml +++ b/docs/diagrams/08_nats_create_update_peer.puml @@ -1,50 +1,42 @@ @startuml -title NATS — CREATE_RESOURCE : Pair A découvre Pair B et établit le stream +title NATS — CREATE_RESOURCE : Peer A Create/Update Peer B & establishing stream -participant "App Pair A (oc-api)" as AppA +participant "App Peer A (oc-api)" as AppA participant "NATS A" as NATSA participant "Node A" as NodeA participant "StreamService A" as StreamA participant "Node B" as NodeB participant "StreamService B" as StreamB -participant "DB Pair A (oc-lib)" as DBA +participant "DB Peer A (oc-lib)" as DBA -note over AppA: Pair B vient d'être découvert\n(via indexeur ou manuel) +note over AppA: Peer B is discovered\n(per indexer or manually) AppA -> NATSA: Publish(CREATE_RESOURCE, {\n FromApp:"oc-api",\n Datatype:PEER,\n Payload: Peer B {StreamAddress_B, Relation:PARTNER}\n}) NATSA -> NodeA: ListenNATS callback → CREATE_RESOURCE -NodeA -> NodeA: resp.FromApp == "oc-discovery" ? → Non, continuer +NodeA -> NodeA: if from himself ? → No, continue NodeA -> NodeA: json.Unmarshal(payload) → peer.Peer B -NodeA -> NodeA: pp.AddrInfoFromString(B.StreamAddress) -note over NodeA: ad_B = {ID: PeerID_B, Addrs: [...]} - -NodeA -> StreamA: Mu.Lock() alt peer B.Relation == PARTNER NodeA -> StreamA: ConnectToPartner(B.StreamAddress) - StreamA -> StreamA: AddrInfoFromString(B.StreamAddress) → ad_B StreamA -> NodeB: Connect (libp2p) StreamA -> NodeB: NewStream /opencloud/resource/heartbeat/partner/1.0 StreamA -> NodeB: json.Encode(Heartbeat{Name_A, DID_A, PeerID_A}) NodeB -> StreamB: HandlePartnerHeartbeat(stream) StreamB -> StreamB: CheckHeartbeat → bandwidth challenge - StreamB -> StreamA: Echo(payload) StreamB -> StreamB: streams[ProtocolHeartbeatPartner][PeerID_A] = {DID_A, Expiry=now+10s} StreamA -> StreamA: streams[ProtocolHeartbeatPartner][PeerID_B] = {DID_B, Expiry=now+10s} - note over StreamA,StreamB: Stream partner long-lived établi\ndans les deux sens -else peer B.Relation != PARTNER (révocation / blacklist) - note over NodeA: Supprimer tous les streams vers Pair B - loop Pour chaque protocole dans Streams + note over StreamA,StreamB: Stream partner long-lived établi\nbi-directionnal +else peer B.Relation != PARTNER (revoke / blacklist) + note over NodeA: Suppress all streams onto Peer B + loop For every Streams NodeA -> StreamA: streams[proto][PeerID_B].Stream.Close() NodeA -> StreamA: delete(streams[proto], PeerID_B) end end - -NodeA -> StreamA: Mu.Unlock() -NodeA -> DBA: (pas de write direct ici — géré par l'app source) +NodeA -> DBA: (no write — only app source manually add peer) @enduml diff --git a/docs/diagrams/09_nats_propagation.mmd b/docs/diagrams/09_nats_propagation.mmd deleted file mode 100644 index 8b3bd73..0000000 --- a/docs/diagrams/09_nats_propagation.mmd +++ /dev/null @@ -1,66 +0,0 @@ -sequenceDiagram - title NATS — PROPALGATION_EVENT : Pair A propage vers Pair B - - participant AppA as App Pair A - participant NATSA as NATS A - participant NodeA as Node A - participant StreamA as StreamService A - participant NodeB as Node B - participant NATSB as NATS B - participant DBB as DB Pair B (oc-lib) - - AppA->>NATSA: Publish(PROPALGATION_EVENT, {Action, DataType, Payload}) - NATSA->>NodeA: ListenNATS callback → PROPALGATION_EVENT - NodeA->>NodeA: resp.FromApp != "oc-discovery" ? → continuer - NodeA->>NodeA: json.Unmarshal → PropalgationMessage{Action, DataType, Payload} - - alt Action == PB_DELETE - NodeA->>StreamA: ToPartnerPublishEvent(PB_DELETE, dt, user, payload) - StreamA->>StreamA: searchPeer(PARTNER) → [Pair B, ...] - StreamA->>NodeB: write(PeerID_B, addr_B, dt, user, payload, ProtocolDeleteResource) - Note over NodeB: /opencloud/resource/delete/1.0 - - NodeB->>NodeB: handleEventFromPartner(evt, ProtocolDeleteResource) - NodeB->>NATSB: SetNATSPub(REMOVE_RESOURCE, {DataType, resource JSON}) - NATSB->>DBB: Supprimer ressource dans DB B - - else Action == PB_UPDATE (via ProtocolUpdateResource) - NodeA->>StreamA: ToPartnerPublishEvent(PB_UPDATE, dt, user, payload) - StreamA->>NodeB: write → /opencloud/resource/update/1.0 - NodeB->>NATSB: SetNATSPub(CREATE_RESOURCE, {DataType, resource JSON}) - NATSB->>DBB: Upsert ressource dans DB B - - else Action == PB_CONSIDERS + WORKFLOW_EXECUTION - NodeA->>NodeA: Unmarshal → executionConsidersPayload{PeerIDs:[PeerID_B, ...]} - loop Pour chaque peer_id cible - NodeA->>StreamA: PublishCommon(dt, user, PeerID_B, ProtocolConsidersResource, payload) - StreamA->>NodeB: write → /opencloud/resource/considers/1.0 - NodeB->>NodeB: passConsidering(evt) - NodeB->>NATSB: SetNATSPub(PROPALGATION_EVENT, {PB_CONSIDERS, dt, payload}) - NATSB->>DBB: (traité par oc-workflow sur NATS B) - end - - else Action == PB_PLANNER (broadcast) - NodeA->>NodeA: Unmarshal → {peer_id: nil, ...payload} - loop Pour chaque stream ProtocolSendPlanner ouvert - NodeA->>StreamA: PublishCommon(nil, user, pid, ProtocolSendPlanner, payload) - StreamA->>NodeB: write → /opencloud/resource/planner/1.0 - end - - else Action == PB_CLOSE_PLANNER - NodeA->>NodeA: Unmarshal → {peer_id: PeerID_B} - NodeA->>StreamA: Streams[ProtocolSendPlanner][PeerID_B].Stream.Close() - NodeA->>StreamA: delete(Streams[ProtocolSendPlanner], PeerID_B) - - else Action == PB_SEARCH + DataType == PEER - NodeA->>NodeA: Unmarshal → {search: "..."} - NodeA->>NodeA: GetPeerRecord(ctx, search) - Note over NodeA: Résolution via DB A + Indexer + DHT - NodeA->>NATSA: SetNATSPub(SEARCH_EVENT, {PEER, PeerRecord JSON}) - NATSA->>NATSA: (AppA reçoit le résultat) - - else Action == PB_SEARCH + autre DataType - NodeA->>NodeA: Unmarshal → {type:"all"|"known"|"partner", search:"..."} - NodeA->>NodeA: PubSubService.SearchPublishEvent(ctx, dt, type, user, search) - Note over NodeA: Voir diagrammes 10 et 11 - end diff --git a/docs/diagrams/09_nats_propagation.puml b/docs/diagrams/09_nats_propagation.puml index d5bc2f5..b18e6f9 100644 --- a/docs/diagrams/09_nats_propagation.puml +++ b/docs/diagrams/09_nats_propagation.puml @@ -1,50 +1,55 @@ @startuml -title NATS — PROPALGATION_EVENT : Pair A propage vers Pair B +title NATS — PROPALGATION_EVENT : Peer A propalgate to Peer B lookup participant "App Pair A" as AppA participant "NATS A" as NATSA participant "Node A" as NodeA participant "StreamService A" as StreamA -participant "Node B" as NodeB +participant "Node Partner B" as PeerB +participant "Node C" as PeerC + participant "NATS B" as NATSB participant "DB Pair B (oc-lib)" as DBB +note over App: only our proper resource (db data) can be propalgate : creator_id==self + AppA -> NATSA: Publish(PROPALGATION_EVENT, {Action, DataType, Payload}) NATSA -> NodeA: ListenNATS callback → PROPALGATION_EVENT -NodeA -> NodeA: resp.FromApp != "oc-discovery" ? → continuer +NodeA -> NodeA: propalgate from himself ? → no, continue NodeA -> NodeA: json.Unmarshal → PropalgationMessage{Action, DataType, Payload} alt Action == PB_DELETE NodeA -> StreamA: ToPartnerPublishEvent(PB_DELETE, dt, user, payload) - StreamA -> StreamA: searchPeer(PARTNER) → [Pair B, ...] + StreamA -> StreamA: searchPeer(PARTNER) → [Peer Partner B, ...] StreamA -> NodeB: write(PeerID_B, addr_B, dt, user, payload, ProtocolDeleteResource) note over NodeB: /opencloud/resource/delete/1.0 NodeB -> NodeB: handleEventFromPartner(evt, ProtocolDeleteResource) NodeB -> NATSB: SetNATSPub(REMOVE_RESOURCE, {DataType, resource JSON}) - NATSB -> DBB: Supprimer ressource dans DB B + NATSB -> DBB: Suppress ressource into DB B -else Action == PB_UPDATE (via ProtocolUpdateResource) +else Action == PB_UPDATE (per ProtocolUpdateResource) NodeA -> StreamA: ToPartnerPublishEvent(PB_UPDATE, dt, user, payload) + StreamA -> StreamA: searchPeer(PARTNER) → [Peer Partner B, ...] StreamA -> NodeB: write → /opencloud/resource/update/1.0 NodeB -> NATSB: SetNATSPub(CREATE_RESOURCE, {DataType, resource JSON}) NATSB -> DBB: Upsert ressource dans DB B -else Action == PB_CONSIDERS + WORKFLOW_EXECUTION +else Action == PB_CREATE (per ProtocolCreateResource) + NodeA -> StreamA: ToPartnerPublishEvent(PB_UPDATE, dt, user, payload) + StreamA -> StreamA: searchPeer(PARTNER) → [Peer Partner B, ...] + StreamA -> NodeB: write → /opencloud/resource/create/1.0 + NodeB -> NATSB: SetNATSPub(CREATE_RESOURCE, {DataType, resource JSON}) + NATSB -> DBB: Create ressource dans DB B + +else Action == PB_CONSIDERS (is a considering a previous action, such as planning or creating resource) NodeA -> NodeA: Unmarshal → executionConsidersPayload{PeerIDs:[PeerID_B, ...]} - loop Pour chaque peer_id cible + loop For every peer_id targeted NodeA -> StreamA: PublishCommon(dt, user, PeerID_B, ProtocolConsidersResource, payload) StreamA -> NodeB: write → /opencloud/resource/considers/1.0 NodeB -> NodeB: passConsidering(evt) NodeB -> NATSB: SetNATSPub(PROPALGATION_EVENT, {PB_CONSIDERS, dt, payload}) - NATSB -> DBB: (traité par oc-workflow sur NATS B) - end - -else Action == PB_PLANNER (broadcast) - NodeA -> NodeA: Unmarshal → {peer_id: nil, ...payload} - loop Pour chaque stream ProtocolSendPlanner ouvert - NodeA -> StreamA: PublishCommon(nil, user, pid, ProtocolSendPlanner, payload) - StreamA -> NodeB: write → /opencloud/resource/planner/1.0 + NATSB -> DBB: (treat per emmitters app of a previous action on NATS B) end else Action == PB_CLOSE_PLANNER @@ -53,16 +58,16 @@ else Action == PB_CLOSE_PLANNER NodeA -> StreamA: delete(Streams[ProtocolSendPlanner], PeerID_B) else Action == PB_SEARCH + DataType == PEER - NodeA -> NodeA: Unmarshal → {search: "..."} + NodeA -> NodeA: read → {search: "..."} NodeA -> NodeA: GetPeerRecord(ctx, search) - note over NodeA: Résolution via DB A + Indexer + DHT + note over NodeA: Resolved per DB A or Indexer + DHT NodeA -> NATSA: SetNATSPub(SEARCH_EVENT, {PEER, PeerRecord JSON}) - NATSA -> NATSA: (AppA reçoit le résultat) + NATSA -> NATSA: (AppA retrieve results) -else Action == PB_SEARCH + autre DataType - NodeA -> NodeA: Unmarshal → {type:"all"|"known"|"partner", search:"..."} +else Action == PB_SEARCH + other DataType + NodeA -> NodeA: read → {type:"all"|"known"|"partner", search:"..."} NodeA -> NodeA: PubSubService.SearchPublishEvent(ctx, dt, type, user, search) - note over NodeA: Voir diagrammes 10 et 11 + note over NodeA: Watch after pubsub_search & stream_search diagrams end @enduml diff --git a/docs/diagrams/10_pubsub_search.mmd b/docs/diagrams/10_pubsub_search.mmd deleted file mode 100644 index 267c64a..0000000 --- a/docs/diagrams/10_pubsub_search.mmd +++ /dev/null @@ -1,52 +0,0 @@ -sequenceDiagram - title PubSub — Recherche gossip globale (type "all") : Pair A cherche, Pair B répond - - participant AppA as App Pair A - participant NATSA as NATS A - participant NodeA as Node A - participant PubSubA as PubSubService A - participant GossipSub as GossipSub libp2p (mesh) - participant NodeB as Node B - participant PubSubB as PubSubService B - participant DBB as DB Pair B (oc-lib) - participant StreamB as StreamService B - participant StreamA as StreamService A - - AppA->>NATSA: Publish(PROPALGATION_EVENT, {PB_SEARCH, type:"all", search:"gpu"}) - NATSA->>NodeA: ListenNATS → PB_SEARCH (type "all") - - NodeA->>PubSubA: SearchPublishEvent(ctx, dt, "all", user, "gpu") - PubSubA->>PubSubA: publishEvent(PB_SEARCH, user, {search:"gpu"}) - PubSubA->>PubSubA: GenerateNodeID() → from = DID_A - PubSubA->>PubSubA: priv_A.Sign(event body) → sig - PubSubA->>PubSubA: Build Event{Type:"search", From:DID_A, Payload:{search:"gpu"}, Sig} - - PubSubA->>GossipSub: topic.Join("search") - PubSubA->>GossipSub: topic.Publish(ctx, json(Event)) - - GossipSub-->>NodeB: Message propagé (gossip mesh) - - NodeB->>PubSubB: subscribeEvents écoute topic "search#" - PubSubB->>PubSubB: json.Unmarshal → Event{From: DID_A} - - PubSubB->>NodeB: GetPeerRecord(ctx, DID_A) - Note over NodeB: Résolution Pair A via DB B ou Indexer - NodeB-->>PubSubB: Peer A {PublicKey_A, Relation, ...} - - PubSubB->>PubSubB: event.Verify(Peer A) → valide sig_A - PubSubB->>PubSubB: handleEventSearch(ctx, evt, PB_SEARCH) - - PubSubB->>StreamB: SendResponse(Peer A, evt) - StreamB->>DBB: Search(COMPUTE + STORAGE + ..., filters{creator=self, access=PUBLIC OR partnerships[PeerID_A]}, search="gpu") - DBB-->>StreamB: [Resource1, Resource2, ...] - - loop Pour chaque ressource matchée - StreamB->>StreamB: write(PeerID_A, addr_A, dt, resource JSON, ProtocolSearchResource) - StreamB->>StreamA: NewStream /opencloud/resource/search/1.0 - StreamB->>StreamA: json.Encode(Event{Type:search, From:DID_B, DataType, Payload:resource}) - end - - StreamA->>StreamA: readLoop → handleEvent(ProtocolSearchResource, evt) - StreamA->>StreamA: retrieveResponse(evt) - StreamA->>NATSA: SetNATSPub(SEARCH_EVENT, {DataType, resource JSON}) - NATSA->>AppA: Résultats de recherche de Pair B diff --git a/docs/diagrams/10_pubsub_search.puml b/docs/diagrams/10_pubsub_search.puml index 1249b78..6df3260 100644 --- a/docs/diagrams/10_pubsub_search.puml +++ b/docs/diagrams/10_pubsub_search.puml @@ -1,54 +1,58 @@ @startuml -title PubSub — Recherche gossip globale (type "all") : Pair A cherche, Pair B répond +title PubSub — Gossip Global search (type "all") : Peer A searching, Peer B answering -participant "App Pair A" as AppA +participant "App UI A" as UIA +participant "App Peer A" as AppA participant "NATS A" as NATSA participant "Node A" as NodeA +participant "StreamService A" as StreamA participant "PubSubService A" as PubSubA participant "GossipSub libp2p (mesh)" as GossipSub participant "Node B" as NodeB participant "PubSubService B" as PubSubB -participant "DB Pair B (oc-lib)" as DBB +participant "DB Peer B (oc-lib)" as DBB participant "StreamService B" as StreamB -participant "StreamService A" as StreamA -AppA -> NATSA: Publish(PROPALGATION_EVENT, {PB_SEARCH, type:"all", search:"gpu"}) +UIA -> AppA: websocket subscription, sending {type:"all", search:"search"} in query + +AppA -> NATSA: Publish(PROPALGATION_EVENT, {PB_SEARCH, type:"all", search:"search"}) NATSA -> NodeA: ListenNATS → PB_SEARCH (type "all") -NodeA -> PubSubA: SearchPublishEvent(ctx, dt, "all", user, "gpu") -PubSubA -> PubSubA: publishEvent(PB_SEARCH, user, {search:"gpu"}) -PubSubA -> PubSubA: GenerateNodeID() → from = DID_A +NodeA -> PubSubA: SearchPublishEvent(ctx, dt, "all", user, "search") +PubSubA -> PubSubA: publishEvent(PB_SEARCH, user, {search:"search"}) PubSubA -> PubSubA: priv_A.Sign(event body) → sig -PubSubA -> PubSubA: Build Event{Type:"search", From:DID_A, Payload:{search:"gpu"}, Sig} +PubSubA -> PubSubA: Build Event{Type:"search", From:DID_A, Payload:{search:"search"}, Sig} PubSubA -> GossipSub: topic.Join("search") PubSubA -> GossipSub: topic.Publish(ctx, json(Event)) -GossipSub --> NodeB: Message propagé (gossip mesh) +GossipSub --> NodeB: Propalgate message (gossip mesh) -NodeB -> PubSubB: subscribeEvents écoute topic "search#" -PubSubB -> PubSubB: json.Unmarshal → Event{From: DID_A} +NodeB -> PubSubB: subscribeEvents listen to topic "search#" +PubSubB -> PubSubB: read → Event{From: DID_A} PubSubB -> NodeB: GetPeerRecord(ctx, DID_A) -note over NodeB: Résolution Pair A via DB B ou Indexer +note over NodeB: Resolve Peer A per DB B or ask to Indexer NodeB --> PubSubB: Peer A {PublicKey_A, Relation, ...} -PubSubB -> PubSubB: event.Verify(Peer A) → valide sig_A +PubSubB -> PubSubB: event.Verify(Peer A) → valid sig_A PubSubB -> PubSubB: handleEventSearch(ctx, evt, PB_SEARCH) PubSubB -> StreamB: SendResponse(Peer A, evt) -StreamB -> DBB: Search(COMPUTE + STORAGE + ..., filters{creator=self, access=PUBLIC OR partnerships[PeerID_A]}, search="gpu") +StreamB -> DBB: Search(COMPUTE + STORAGE + ..., filters{creator=self, access=PUBLIC OR partnerships[PeerID_A]}, search="search") DBB --> StreamB: [Resource1, Resource2, ...] -loop Pour chaque ressource matchée +loop For every matching resource, only match our own resource creator_id=self_did StreamB -> StreamB: write(PeerID_A, addr_A, dt, resource JSON, ProtocolSearchResource) StreamB -> StreamA: NewStream /opencloud/resource/search/1.0 - StreamB -> StreamA: json.Encode(Event{Type:search, From:DID_B, DataType, Payload:resource}) + StreamB -> StreamA: stream.Encode(Event{Type:search, From:DID_B, DataType, Payload:resource}) end StreamA -> StreamA: readLoop → handleEvent(ProtocolSearchResource, evt) StreamA -> StreamA: retrieveResponse(evt) StreamA -> NATSA: SetNATSPub(SEARCH_EVENT, {DataType, resource JSON}) -NATSA -> AppA: Résultats de recherche de Pair B +NATSA -> AppA: Search results from Peer B + +AppA -> UIA: emit on websocket @enduml diff --git a/docs/diagrams/11_stream_search.mmd b/docs/diagrams/11_stream_search.mmd deleted file mode 100644 index 2583444..0000000 --- a/docs/diagrams/11_stream_search.mmd +++ /dev/null @@ -1,52 +0,0 @@ -sequenceDiagram - title Stream — Recherche directe (type "known"/"partner") : Pair A → Pair B - - participant AppA as App Pair A - participant NATSA as NATS A - participant NodeA as Node A - participant PubSubA as PubSubService A - participant StreamA as StreamService A - participant DBA as DB Pair A (oc-lib) - participant NodeB as Node B - participant StreamB as StreamService B - participant DBB as DB Pair B (oc-lib) - - AppA->>NATSA: Publish(PROPALGATION_EVENT, {PB_SEARCH, type:"partner", search:"gpu"}) - NATSA->>NodeA: ListenNATS → PB_SEARCH (type "partner") - NodeA->>PubSubA: SearchPublishEvent(ctx, dt, "partner", user, "gpu") - - PubSubA->>StreamA: SearchPartnersPublishEvent(dt, user, "gpu") - StreamA->>DBA: Search(PEER, PARTNER) + PeerIDS config - DBA-->>StreamA: [Peer B, ...] - - loop Pour chaque pair partenaire (Pair B) - StreamA->>StreamA: json.Marshal({search:"gpu"}) → payload - StreamA->>StreamA: write(PeerID_B, addr_B, dt, user, payload, ProtocolSearchResource) - StreamA->>NodeB: TempStream /opencloud/resource/search/1.0 - StreamA->>NodeB: json.Encode(Event{Type:search, From:DID_A, DataType, Payload:{search:"gpu"}}) - - NodeB->>StreamB: HandleResponse(stream) → readLoop - StreamB->>StreamB: handleEvent(ProtocolSearchResource, evt) - StreamB->>StreamB: handleEventFromPartner(evt, ProtocolSearchResource) - - alt evt.DataType == -1 (toutes ressources) - StreamB->>DBA: Search(PEER, evt.From=DID_A) - Note over StreamB: Résolution locale ou via GetPeerRecord - StreamB->>StreamB: SendResponse(Peer A, evt) - StreamB->>DBB: Search(ALL_RESOURCES, filter{creator=B + public OR partner A + search:"gpu"}) - DBB-->>StreamB: [Resource1, Resource2, ...] - else evt.DataType spécifié - StreamB->>DBB: Search(DataType, filter{creator=B + access + search:"gpu"}) - DBB-->>StreamB: [Resource1, ...] - end - - loop Pour chaque ressource - StreamB->>StreamA: write(PeerID_A, addr_A, dt, resource JSON, ProtocolSearchResource) - StreamA->>StreamA: readLoop → handleEvent(ProtocolSearchResource, evt) - StreamA->>StreamA: retrieveResponse(evt) - StreamA->>NATSA: SetNATSPub(SEARCH_EVENT, {DataType, resource JSON}) - NATSA->>AppA: Résultat de Pair B - end - end - - Note over NATSA,DBA: Optionnel: App A persiste
les ressources découvertes dans DB A diff --git a/docs/diagrams/11_stream_search.puml b/docs/diagrams/11_stream_search.puml index 1ac14b4..4a7ad6b 100644 --- a/docs/diagrams/11_stream_search.puml +++ b/docs/diagrams/11_stream_search.puml @@ -1,6 +1,7 @@ @startuml -title Stream — Recherche directe (type "known"/"partner") : Pair A → Pair B +title Stream — Direct search (type "known"/"partner") : Peer A → Peer B +participant "App UI A" as UIA participant "App Pair A" as AppA participant "NATS A" as NATSA participant "Node A" as NodeA @@ -11,6 +12,8 @@ participant "Node B" as NodeB participant "StreamService B" as StreamB participant "DB Pair B (oc-lib)" as DBB +UIA -> AppA: websocket subscription, sending {type:"all", search:"search"} in query + AppA -> NATSA: Publish(PROPALGATION_EVENT, {PB_SEARCH, type:"partner", search:"gpu"}) NATSA -> NodeA: ListenNATS → PB_SEARCH (type "partner") NodeA -> PubSubA: SearchPublishEvent(ctx, dt, "partner", user, "gpu") @@ -20,10 +23,9 @@ StreamA -> DBA: Search(PEER, PARTNER) + PeerIDS config DBA --> StreamA: [Peer B, ...] loop Pour chaque pair partenaire (Pair B) - StreamA -> StreamA: json.Marshal({search:"gpu"}) → payload StreamA -> StreamA: write(PeerID_B, addr_B, dt, user, payload, ProtocolSearchResource) StreamA -> NodeB: TempStream /opencloud/resource/search/1.0 - StreamA -> NodeB: json.Encode(Event{Type:search, From:DID_A, DataType, Payload:{search:"gpu"}}) + StreamA -> NodeB: stream.Encode(Event{Type:search, From:DID_A, DataType, Payload:{search:"gpu"}}) NodeB -> StreamB: HandleResponse(stream) → readLoop StreamB -> StreamB: handleEvent(ProtocolSearchResource, evt) @@ -31,11 +33,11 @@ loop Pour chaque pair partenaire (Pair B) alt evt.DataType == -1 (toutes ressources) StreamB -> DBA: Search(PEER, evt.From=DID_A) - note over StreamB: Résolution locale ou via GetPeerRecord + note over StreamB: Local Resolving (DB) or GetPeerRecord (Indexer Way) StreamB -> StreamB: SendResponse(Peer A, evt) StreamB -> DBB: Search(ALL_RESOURCES, filter{creator=B + public OR partner A + search:"gpu"}) DBB --> StreamB: [Resource1, Resource2, ...] - else evt.DataType spécifié + else evt.DataType specified StreamB -> DBB: Search(DataType, filter{creator=B + access + search:"gpu"}) DBB --> StreamB: [Resource1, ...] end @@ -45,10 +47,8 @@ loop Pour chaque pair partenaire (Pair B) StreamA -> StreamA: readLoop → handleEvent(ProtocolSearchResource, evt) StreamA -> StreamA: retrieveResponse(evt) StreamA -> NATSA: SetNATSPub(SEARCH_EVENT, {DataType, resource JSON}) - NATSA -> AppA: Résultat de Pair B + NATSA -> AppA: Peer B results + AppA -> UIA: emit on websocket end end - -note over NATSA,DBA: Optionnel: App A persiste\nles ressources découvertes dans DB A - @enduml diff --git a/docs/diagrams/12_partner_heartbeat.mmd b/docs/diagrams/12_partner_heartbeat.mmd deleted file mode 100644 index 2fc21e3..0000000 --- a/docs/diagrams/12_partner_heartbeat.mmd +++ /dev/null @@ -1,58 +0,0 @@ -sequenceDiagram - title Stream — Partner Heartbeat et propagation CRUD Pair A ↔ Pair B - - participant DBA as DB Pair A (oc-lib) - participant StreamA as StreamService A - participant NodeA as Node A - participant NodeB as Node B - participant StreamB as StreamService B - participant NATSB as NATS B - participant DBB as DB Pair B (oc-lib) - participant NATSA as NATS A - - Note over StreamA: Démarrage → connectToPartners() - - StreamA->>DBA: Search(PEER, PARTNER) + PeerIDS config - DBA-->>StreamA: [Peer B, ...] - - StreamA->>NodeB: Connect (libp2p) - StreamA->>NodeB: NewStream /opencloud/resource/heartbeat/partner/1.0 - StreamA->>NodeB: json.Encode(Heartbeat{Name_A, DID_A, PeerID_A, IndexersBinded_A}) - - NodeB->>StreamB: HandlePartnerHeartbeat(stream) - StreamB->>StreamB: CheckHeartbeat → bandwidth challenge - StreamB->>StreamA: Echo(payload) - StreamB->>StreamB: streams[ProtocolHeartbeatPartner][PeerID_A] = {DID_A, Expiry=now+10s} - - StreamA->>StreamA: streams[ProtocolHeartbeatPartner][PeerID_B] = {DID_B, Expiry=now+10s} - - Note over StreamA,StreamB: Stream partner long-lived établi
GC toutes les 8s (StreamService A)
GC toutes les 30s (StreamService B) - - Note over NATSA: Pair A reçoit PROPALGATION_EVENT{PB_DELETE, dt:"storage", payload:res} - - NATSA->>NodeA: ListenNATS → ToPartnerPublishEvent(PB_DELETE, dt, user, payload) - NodeA->>StreamA: ToPartnerPublishEvent(ctx, PB_DELETE, dt_storage, user, payload) - - alt dt == PEER (mise à jour relation partenaire) - StreamA->>StreamA: json.Unmarshal → peer.Peer B updated - alt B.Relation == PARTNER - StreamA->>NodeB: ConnectToPartner(B.StreamAddress) - Note over StreamA,NodeB: Reconnexion heartbeat si relation upgrade - else B.Relation != PARTNER - loop Tous les protocoles - StreamA->>StreamA: delete(streams[proto][PeerID_B]) - StreamA->>NodeB: (streams fermés) - end - end - else dt != PEER (ressource ordinaire) - StreamA->>DBA: Search(PEER, PARTNER) → [Pair B, ...] - loop Pour chaque protocole partner (Create/Update/Delete) - StreamA->>NodeB: write(PeerID_B, addr_B, dt, user, payload, ProtocolDeleteResource) - Note over NodeB: /opencloud/resource/delete/1.0 - - NodeB->>StreamB: HandleResponse → readLoop - StreamB->>StreamB: handleEventFromPartner(evt, ProtocolDeleteResource) - StreamB->>NATSB: SetNATSPub(REMOVE_RESOURCE, {DataType, resource JSON}) - NATSB->>DBB: Supprimer ressource dans DB B - end - end diff --git a/docs/diagrams/13_planner_flow.mmd b/docs/diagrams/13_planner_flow.mmd deleted file mode 100644 index 4d6f792..0000000 --- a/docs/diagrams/13_planner_flow.mmd +++ /dev/null @@ -1,49 +0,0 @@ -sequenceDiagram - title Stream — Session Planner : Pair A demande le plan de Pair B - - participant AppA as App Pair A (oc-booking) - participant NATSA as NATS A - participant NodeA as Node A - participant StreamA as StreamService A - participant NodeB as Node B - participant StreamB as StreamService B - participant DBB as DB Pair B (oc-lib) - participant NATSB as NATS B - - %% Ouverture session planner - AppA->>NATSA: Publish(PROPALGATION_EVENT, {PB_PLANNER, peer_id:PeerID_B, payload:{}}) - NATSA->>NodeA: ListenNATS → PB_PLANNER - - NodeA->>NodeA: Unmarshal → {peer_id: PeerID_B, payload: {}} - NodeA->>StreamA: PublishCommon(nil, user, PeerID_B, ProtocolSendPlanner, {}) - Note over StreamA: WaitResponse=true, TTL=24h
Stream long-lived vers Pair B - StreamA->>NodeB: TempStream /opencloud/resource/planner/1.0 - StreamA->>NodeB: json.Encode(Event{Type:planner, From:DID_A, Payload:{}}) - - NodeB->>StreamB: HandleResponse → readLoop(ProtocolSendPlanner) - StreamB->>StreamB: handleEvent(ProtocolSendPlanner, evt) - StreamB->>StreamB: sendPlanner(evt) - - alt evt.Payload vide (requête initiale) - StreamB->>DBB: planner.GenerateShallow(AdminRequest) - DBB-->>StreamB: plan (shallow booking plan de Pair B) - StreamB->>StreamA: PublishCommon(nil, user, DID_A, ProtocolSendPlanner, planJSON) - StreamA->>NodeA: json.Encode(Event{plan de B}) - NodeA->>NATSA: (forwardé à AppA via SEARCH_EVENT ou PLANNER event) - NATSA->>AppA: Plan de Pair B - else evt.Payload non vide (mise à jour planner) - StreamB->>StreamB: m["peer_id"] = evt.From (DID_A) - StreamB->>NATSB: SetNATSPub(PROPALGATION_EVENT, {PB_PLANNER, peer_id:DID_A, payload:plan}) - NATSB->>DBB: (oc-booking traite le plan sur NATS B) - end - - %% Fermeture session planner - AppA->>NATSA: Publish(PROPALGATION_EVENT, {PB_CLOSE_PLANNER, peer_id:PeerID_B}) - NATSA->>NodeA: ListenNATS → PB_CLOSE_PLANNER - - NodeA->>NodeA: Unmarshal → {peer_id: PeerID_B} - NodeA->>StreamA: Mu.Lock() - NodeA->>StreamA: Streams[ProtocolSendPlanner][PeerID_B].Stream.Close() - NodeA->>StreamA: delete(Streams[ProtocolSendPlanner], PeerID_B) - NodeA->>StreamA: Mu.Unlock() - Note over StreamA,NodeB: Stream planner fermé — session terminée diff --git a/docs/diagrams/14_native_offload_gc.mmd b/docs/diagrams/14_native_offload_gc.mmd deleted file mode 100644 index 7a6ed14..0000000 --- a/docs/diagrams/14_native_offload_gc.mmd +++ /dev/null @@ -1,59 +0,0 @@ -sequenceDiagram - title Native Indexer — Boucles background (offload, DHT refresh, GC streams) - - participant IndexerA as Indexer A (enregistré) - participant IndexerB as Indexer B (enregistré) - participant Native as Native Indexer - participant DHT as DHT Kademlia - participant NodeA as Node A (responsible peer) - - Note over Native: runOffloadLoop — toutes les 30s - - loop Toutes les 30s - Native->>Native: len(responsiblePeers) > 0 ? - Note over Native: responsiblePeers = peers pour lesquels
le native a fait selfDelegate (aucun indexer dispo) - alt Des responsible peers existent (ex: Node A) - Native->>Native: reachableLiveIndexers() - Note over Native: Filtre liveIndexers par TTL
ping PeerIsAlive pour chaque candidat - alt Indexers A et B maintenant joignables - Native->>Native: responsiblePeers = {} (libère Node A et autres) - Note over Native: Node A se reconnectera
au prochain ConnectToNatives - else Toujours aucun indexer - Note over Native: Node A reste sous la responsabilité du native - end - end - end - - Note over Native: refreshIndexersFromDHT — toutes les 30s - - loop Toutes les 30s - Native->>Native: Collecter tous les knownPeerIDs
= {PeerID_A, PeerID_B, ...} - loop Pour chaque PeerID connu - Native->>Native: liveIndexers[PeerID] encore frais ? - alt Entrée manquante ou expirée - Native->>DHT: SearchValue(ctx 5s, "/indexer/"+PeerID) - DHT-->>Native: channel de bytes - loop Pour chaque résultat DHT - Native->>Native: Unmarshal → liveIndexerEntry - Native->>Native: Garder le meilleur (ExpiresAt le plus récent, valide) - end - Native->>Native: liveIndexers[PeerID] = best entry - Note over Native: "native: refreshed indexer from DHT" - end - end - end - - Note over Native: LongLivedStreamRecordedService GC — toutes les 30s - - loop Toutes les 30s - Native->>Native: gc() — lock StreamRecords[Heartbeat] - loop Pour chaque StreamRecord (Indexer A, B, ...) - Native->>Native: now > rec.Expiry ?
OU timeSince(LastSeen) > 2×TTL restant ? - alt Pair périmé (ex: Indexer B disparu) - Native->>Native: Supprimer Indexer B de TOUS les maps de protocoles - Note over Native: Stream heartbeat fermé
liveIndexers[PeerID_B] expirera naturellement - end - end - end - - Note over IndexerA: Indexer A continue à heartbeater normalement
et reste dans StreamRecords + liveIndexers diff --git a/docs/diagrams/15_archi_config_nominale.puml b/docs/diagrams/15_archi_config_nominale.puml new file mode 100644 index 0000000..1ddd082 --- /dev/null +++ b/docs/diagrams/15_archi_config_nominale.puml @@ -0,0 +1,49 @@ +@startuml 15_archi_config_nominale +skinparam componentStyle rectangle +skinparam backgroundColor white +skinparam defaultTextAlignment center + +title C1 — Topologie nominale\n2 natifs · 2 indexeurs · 2 nœuds + +package "Couche 1 — Mesh natif" #E8F4FD { + component "Native A\n(hub autoritaire)" as NA #AED6F1 + component "Native B\n(hub autoritaire)" as NB #AED6F1 + NA <--> NB : heartbeat /opencloud/heartbeat/1.0 (20s)\n+ gossip PubSub oc-indexer-registry +} + +package "Couche 2 — Indexeurs" #E9F7EF { + component "Indexer A\n(DHT server)" as IA #A9DFBF + component "Indexer B\n(DHT server)" as IB #A9DFBF +} + +package "Couche 3 — Nœuds" #FEFBD8 { + component "Node 1" as N1 #FAF0BE + component "Node 2" as N2 #FAF0BE +} + +' Enregistrements (one-shot, 60s) +IA -[#117A65]--> NA : subscribe signé (60s)\n/opencloud/native/subscribe/1.0 +IA -[#117A65]--> NB : subscribe signé (60s) +IB -[#117A65]--> NA : subscribe signé (60s) +IB -[#117A65]--> NB : subscribe signé (60s) + +' Heartbeats indexeurs → natifs (long-lived, 20s) +IA -[#27AE60]..> NA : heartbeat (20s) +IA -[#27AE60]..> NB : heartbeat (20s) +IB -[#27AE60]..> NA : heartbeat (20s) +IB -[#27AE60]..> NB : heartbeat (20s) + +' Heartbeats nœuds → indexeurs (long-lived, 20s) +N1 -[#E67E22]--> IA : heartbeat long-lived (20s)\n/opencloud/heartbeat/1.0 +N1 -[#E67E22]--> IB : heartbeat long-lived (20s) +N2 -[#E67E22]--> IA : heartbeat long-lived (20s) +N2 -[#E67E22]--> IB : heartbeat long-lived (20s) + +note as Legend + Légende : + ──► enregistrement one-shot (signé) + ···► heartbeat long-lived (20s) + ──► heartbeat nœud → indexeur (20s) +end note + +@enduml diff --git a/docs/diagrams/16_archi_config_seed.puml b/docs/diagrams/16_archi_config_seed.puml new file mode 100644 index 0000000..e2e5a4f --- /dev/null +++ b/docs/diagrams/16_archi_config_seed.puml @@ -0,0 +1,38 @@ +@startuml 16_archi_config_seed +skinparam componentStyle rectangle +skinparam backgroundColor white +skinparam defaultTextAlignment center + +title C2 — Mode seed (sans natif)\nIndexerAddresses seuls · AdmittedAt = zero + +package "Couche 2 — Indexeurs seeds" #E9F7EF { + component "Indexer A\n(seed, AdmittedAt=0)" as IA #A9DFBF + component "Indexer B\n(seed, AdmittedAt=0)" as IB #A9DFBF +} + +package "Couche 3 — Nœuds" #FEFBD8 { + component "Node 1" as N1 #FAF0BE + component "Node 2" as N2 #FAF0BE +} + +note as NNative #FFDDDD + Aucun natif configuré. + AdmittedAt = zero → IsStableVoter() = false + Phase 2 sans votants : Phase 1 conservée directement. + Risque D20 : circularité du trust (seeds se valident mutuellement). +end note + +' Heartbeats nœuds → indexeurs seeds +N1 -[#E67E22]--> IA : heartbeat long-lived (20s) +N1 -[#E67E22]--> IB : heartbeat long-lived (20s) +N2 -[#E67E22]--> IA : heartbeat long-lived (20s) +N2 -[#E67E22]--> IB : heartbeat long-lived (20s) + +note bottom of IA + Après 2s : goroutine async + fetchNativeFromIndexers → ? + Si natif trouvé → ConnectToNatives (upgrade vers C1) + Si non → mode indexeur pur (D20 actif) +end note + +@enduml diff --git a/docs/diagrams/17_startup_consensus_phase1_phase2.puml b/docs/diagrams/17_startup_consensus_phase1_phase2.puml new file mode 100644 index 0000000..119eab7 --- /dev/null +++ b/docs/diagrams/17_startup_consensus_phase1_phase2.puml @@ -0,0 +1,63 @@ +@startuml 17_startup_consensus_phase1_phase2 +title Démarrage avec natifs — Phase 1 (admission) + Phase 2 (vivacité) + +participant "Node / Indexer\n(appelant)" as Caller +participant "Native A" as NA +participant "Native B" as NB +participant "Indexer A" as IA +participant "Indexer B" as IB + +note over Caller: ConnectToNatives()\nNativeIndexerAddresses configuré + +== Étape 0 : heartbeat vers le mesh natif == +Caller -> NA: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) +Caller -> NB: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) + +== Étape 1 : fetch pool en parallèle == +par Fetch parallèle (timeout 6s) + Caller -> NA: GET /opencloud/native/indexers/1.0\nGetIndexersRequest{Count: max, FillRates demandés} + NA -> NA: reachableLiveIndexers()\ntri par w(F) = fillRate×(1−fillRate) + NA --> Caller: GetIndexersResponse{Indexers:[IA,IB], FillRates:{IA:0.3, IB:0.6}} +else + Caller -> NB: GET /opencloud/native/indexers/1.0 + NB -> NB: reachableLiveIndexers() + NB --> Caller: GetIndexersResponse{Indexers:[IA,IB], FillRates:{IA:0.3, IB:0.6}} +end par + +note over Caller: Fusion + dédup → candidates = [IA, IB]\nisFallback = false + +== Étape 2a : Phase 1 — Admission native (clientSideConsensus) == +par Consensus parallèle (timeout 3s par natif, 4s total) + Caller -> NA: /opencloud/native/consensus/1.0\nConsensusRequest{Candidates:[IA,IB]} + NA -> NA: croiser avec liveIndexers + NA --> Caller: ConsensusResponse{Trusted:[IA,IB], Suggestions:[]} +else + Caller -> NB: /opencloud/native/consensus/1.0\nConsensusRequest{Candidates:[IA,IB]} + NB -> NB: croiser avec liveIndexers + NB --> Caller: ConsensusResponse{Trusted:[IA], Suggestions:[IC]} +end par + +note over Caller: IA → 2/2 votes → confirmé ✓\nIB → 1/2 vote → refusé ✗\nadmittedAt = time.Now() + +== Étape 2b : Phase 2 — Liveness vote (indexerLivenessVote) == +note over Caller: Cherche votants stables dans StaticIndexerMeta\n(AdmittedAt != zero, age >= MinStableAge=2min) + +alt Votants stables disponibles + par Phase 2 parallèle (timeout 3s) + Caller -> IA: /opencloud/indexer/consensus/1.0\nIndexerConsensusRequest{Candidates:[IA]} + IA -> IA: vérifier StreamRecords[ProtocolHB][candidate]\nLastSeen ≤ 2×60s && LastScore ≥ 30 + IA --> Caller: IndexerConsensusResponse{Alive:[IA]} + end par + note over Caller: IA confirmé vivant par quorum > 0.5 +else Aucun votant stable (premier démarrage) + note over Caller: Phase 1 conservée directement\n(aucun votant MinStableAge atteint) +end + +== Étape 3 : remplacement StaticIndexers == +Caller -> Caller: replaceStaticIndexers(pool={IA}, admittedAt)\nStaticIndexerMeta[IA].AdmittedAt = time.Now() + +== Étape 4 : heartbeat long-lived vers pool == +Caller -> IA: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) +note over Caller: Pool actif. NudgeIndexerHeartbeat() + +@enduml diff --git a/docs/diagrams/18_startup_seed_discovers_native.puml b/docs/diagrams/18_startup_seed_discovers_native.puml new file mode 100644 index 0000000..b986c5c --- /dev/null +++ b/docs/diagrams/18_startup_seed_discovers_native.puml @@ -0,0 +1,51 @@ +@startuml 18_startup_seed_discovers_native +title C2 → C1 — Seed découvre un natif (upgrade async) + +participant "Node / Indexer\\n(seed mode)" as Caller +participant "Indexer A\\n(seed)" as IA +participant "Indexer B\\n(seed)" as IB +participant "Native A\\n(découvert)" as NA + +note over Caller: Démarrage sans NativeIndexerAddresses\\nStaticIndexers = [IA, IB] (AdmittedAt=0) + +== Phase initiale seed == +Caller -> IA: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) +Caller -> IB: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) + +note over Caller: Pool actif en mode seed.\\nIsStableVoter() = false (AdmittedAt=0)\\nPhase 2 sans votants → Phase 1 conservée. + +== Goroutine async après 2s == +note over Caller: time.Sleep(2s)\\nfetchNativeFromIndexers() + +Caller -> IA: GET /opencloud/indexer/natives/1.0 +IA --> Caller: GetNativesResponse{Natives:[NA]} + +note over Caller: Natif découvert : NA\\nAppel ConnectToNatives([NA]) + +== Upgrade vers mode nominal (ConnectToNatives) == +Caller -> NA: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) + +par Fetch pool depuis natif (timeout 6s) + Caller -> NA: GET /opencloud/native/indexers/1.0\\nGetIndexersRequest{Count: max} + NA -> NA: reachableLiveIndexers()\\ntri par w(F) = fillRate×(1−fillRate) + NA --> Caller: GetIndexersResponse{Indexers:[IA,IB], FillRates:{IA:0.4, IB:0.6}} +end par + +note over Caller: candidates = [IA, IB], isFallback = false + +par Consensus Phase 1 (timeout 3s) + Caller -> NA: /opencloud/native/consensus/1.0\\nConsensusRequest{Candidates:[IA,IB]} + NA -> NA: croiser avec liveIndexers + NA --> Caller: ConsensusResponse{Trusted:[IA,IB], Suggestions:[]} +end par + +note over Caller: IA ✓ IB ✓ (1/1 vote)\\nadmittedAt = time.Now() + +note over Caller: Aucun votant stable (AdmittedAt vient d'être posé)\\nPhase 2 sautée → Phase 1 conservée directement + +== Remplacement pool == +Caller -> Caller: replaceStaticIndexers(pool={IA,IB}, admittedAt)\\nStaticIndexerMeta[IA].AdmittedAt = time.Now()\\nStaticIndexerMeta[IB].AdmittedAt = time.Now() + +note over Caller: Pool upgradé dans la map partagée StaticIndexers.\\nLa goroutine heartbeat existante (démarrée en mode seed)\\ndétecte les nouveaux membres sur le prochain tick (20s).\\nAucune nouvelle goroutine créée.\\nIsStableVoter() deviendra true après MinStableAge (2min).\\nD20 (circularité seeds) éliminé. + +@enduml diff --git a/docs/diagrams/19_failure_indexer_crash.puml b/docs/diagrams/19_failure_indexer_crash.puml new file mode 100644 index 0000000..85a6220 --- /dev/null +++ b/docs/diagrams/19_failure_indexer_crash.puml @@ -0,0 +1,55 @@ +@startuml failure_indexer_crash +title Indexer Failure → replenish from a Native + +participant "Node" as N +participant "Indexer A (alive)" as IA +participant "Indexer B (crashed)" as IB +participant "Native A" as NA +participant "Native B" as NB + +note over N: Active Pool : Indexers = [IA, IB]\\nActive Heartbeat long-lived from IA & IB + +== IB Failure == +IB ->x N: heartbeat fails (sendHeartbeat err) +note over N: doTick() dans SendHeartbeat triggers failure\\n→ delete(Indexers[IB])\\n→ delete(IndexerMeta[IB])\\nUnique heartbeat goroutine continue + +N -> N: go replenishIndexersFromNative(need=1) + +note over N: Reduced Pool to 1 indexers.\\nReplenish triggers with goroutine. + +== Replenish from natives == +par Fetch pool (timeout 6s) + N -> NA: GET /opencloud/native/indexers/1.0\\nGetIndexersRequest{Count: max} + NA -> NA: reachableLiveIndexers()\\n(IB absent because of a expired heartbeat) + NA --> N: GetIndexersResponse{Indexers:[IA,IC], FillRates:{IA:0.4,IC:0.2}} +else + N -> NB: GET /opencloud/native/indexers/1.0 + NB --> N: GetIndexersResponse{Indexers:[IA,IC]} +end par + +note over N: Fusion + duplication → candidates = [IA, IC]\\n(IA already in pool → IC new candidate) + +par Consensus Phase 1 (timeout 4s) + N -> NA: /opencloud/native/consensus/1.0\\nConsensusRequest{Candidates:[IA,IC]} + NA --> N: ConsensusResponse{Trusted:[IA,IC]} +else + N -> NB: /opencloud/native/consensus/1.0 + NB --> N: ConsensusResponse{Trusted:[IA,IC]} +end par + +note over N: IC → 2/2 votes → admit\\nadmittedAt = time.Now() + +par Phase 2 — liveness vote (if stable voters ) + N -> IA: /opencloud/indexer/consensus/1.0\\nIndexerConsensusRequest{Candidates:[IC]} + IA -> IA: StreamRecords[ProtocolHB][IC]\\nLastSeen ≤ 120s && LastScore ≥ 30 + IA --> N: IndexerConsensusResponse{Alive:[IC]} +end par + +note over N: IC confirmed alive → add to pool + +N -> N: replaceStaticIndexers(pool={IA,IC}) +N -> IC: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine long-live) + +note over N: Pool restaured to 2 indexers. + +@enduml diff --git a/docs/diagrams/20_failure_indexers_native_falback.puml b/docs/diagrams/20_failure_indexers_native_falback.puml new file mode 100644 index 0000000..f52bb53 --- /dev/null +++ b/docs/diagrams/20_failure_indexers_native_falback.puml @@ -0,0 +1,51 @@ +@startuml failure_indexers_native_falback +title indexers failures → native IsSelfFallback + +participant "Node" as N +participant "Indexer A (crashed)" as IA +participant "Indexer B (crashed)" as IB +participant "Native A" as NA +participant "Native B" as NB + +note over N: Active Pool : Indexers = [IA, IB] + +== Successive Failures on IA & IB == +IA ->x N: heartbeat failure (sendHeartbeat err) +IB ->x N: heartbeat failure (sendHeartbeat err) + +note over N: doTick() in SendHeartbeat triggers failures\\n→ delete(StaticIndexers[IA]), delete(StaticIndexers[IB])\\n→ delete(StaticIndexerMeta[IA/IB])\\n unique heartbeat goroutine continue. + +N -> N: go replenishIndexersFromNative(need=2) + +== Replenish attempt — natives switches to self-fallback mode == +par Fetch from natives (timeout 6s) + N -> NA: GET /opencloud/native/indexers/1.0 + NA -> NA: reachableLiveIndexers() → 0 alive indexer\\nFallback : included as himself(IsSelfFallback=true) + NA --> N: GetIndexersResponse{Indexers:[NA_addr], IsSelfFallback:true} +else + N -> NB: GET /opencloud/native/indexers/1.0 + NB --> N: GetIndexersResponse{Indexers:[NB_addr], IsSelfFallback:true} +end par + +note over N: isFallback=true → resolvePool avoids consensus\\nadmittedAt = time.Time{} (zero)\\nStaticIndexers = {NA_addr} (native as fallback) + +N -> NA: SendHeartbeat /opencloud/heartbeat/1.0\\n(native as temporary fallback indexers) + +note over NA: responsiblePeers[N] registered.\\nrunOffloadLoop look after real indexers. + +== Reprise IA → runOffloadLoop native side == +IA -> NA: /opencloud/native/subscribe/1.0\\nIndexerRegistration{FillRate: 0} +note over NA: liveIndexers[IA] updated.\\nrunOffloadLoop triggers a real available indexer\\migrate from N to IA. + +== Replenish on next heartbeat tick == +N -> NA: GET /opencloud/native/indexers/1.0 +NA --> N: GetIndexersResponse{Indexers:[IA], IsSelfFallback:false} + +note over N: isFallback=false → Classic Phase 1 + Phase 2 + +N -> N: replaceStaticIndexers(pool={IA}, admittedAt) +N -> IA: SendHeartbeat /opencloud/heartbeat/1.0 + +note over N: Pool restaured. Native self extracted as indexer. + +@enduml diff --git a/docs/diagrams/21_failure_native_one_down.puml b/docs/diagrams/21_failure_native_one_down.puml new file mode 100644 index 0000000..d6e20ed --- /dev/null +++ b/docs/diagrams/21_failure_native_one_down.puml @@ -0,0 +1,46 @@ +@startuml failure_native_one_down +title Native failure, with one still alive + +participant "Indexer A" as IA +participant "Indexer B" as IB +participant "Native A (crashed)" as NA +participant "Native B (alive)" as NB +participant "Node" as N + +note over IA, NB: Native State : IA, IB heartbeats to NA & NB + +== Native A Failure == +NA ->x IA: stream reset +NA ->x IB: stream reset +NA ->x N: stream reset (heartbeat Node → NA) + +== Indexers side : replenishNativesFromPeers == +note over IA: SendHeartbeat(NA) détecte reset\\nAfterDelete(NA)\\nStaticNatives = [NB] (still 1) + +IA -> IA: replenishNativesFromPeers()\\nphase 1 : fetchNativeFromNatives + +IA -> NB: GET /opencloud/native/peers/1.0 +NB --> IA: GetPeersResponse{Peers:[NC]} /' new native if one known '/ + +alt NC disponible + IA -> NC: SendHeartbeat /opencloud/heartbeat/1.0\\nSubscribe /opencloud/native/subscribe/1.0 + note over IA: StaticNatives = [NB, NC]\\nNative Pool restored. +else Aucun peer natif + IA -> IA: fetchNativeFromIndexers()\\nAsk to any indexers their natives + IB --> IA: GetNativesResponse{Natives:[]} /' IB also only got NB '/ + note over IA: Impossible to find a 2e native.\\nStaticNatives = [NB] (degraded but alive). +end + +== Node side : alive indexers pool == +note over N: Node heartbeats to IA & IB.\\nNA Failure does not affect indexers pool.\\nFuture Consensus did not use NB (1/1 vote = quorum OK). + +N -> NB: /opencloud/native/consensus/1.0\\nConsensusRequest{Candidates:[IA,IB]} +NB --> N: ConsensusResponse{Trusted:[IA,IB]} +note over N: Consensus 1/1 alive natif → admit.\\nAuto downgrade of the consensus floor (alive majority). + +== NB side : heartbeat to NA fails == +note over NB: EnsureNativePeers / SendHeartbeat to NA\\nfail (sendHeartbeat err)\\n→ delete(StaticNatives[NA])\\nreplenishNativesFromPeers(NA) triggers + +note over NB: Mesh natif downgraded to NB alone.\\Downgraded but functionnal. + +@enduml diff --git a/docs/diagrams/22_failure_both_natives.puml b/docs/diagrams/22_failure_both_natives.puml new file mode 100644 index 0000000..6d444d4 --- /dev/null +++ b/docs/diagrams/22_failure_both_natives.puml @@ -0,0 +1,60 @@ +@startuml 22_failure_both_natives +title F4 — Panne des 2 natifs → fallback pool pré-validé + +participant "Node" as N +participant "Indexer A\\n(vivant)" as IA +participant "Indexer B\\n(vivant)" as IB +participant "Native A\\n(crashé)" as NA +participant "Native B\\n(crashé)" as NB + +note over N: Pool actif : StaticIndexers = [IA, IB]\\nStaticNatives = [NA, NB]\\nAdmittedAt[IA] et AdmittedAt[IB] posés (stables) + +== Panne simultanée NA et NB == +NA ->x N: stream reset +NB ->x N: stream reset + +N -> N: AfterDelete(NA) + AfterDelete(NB)\\nStaticNatives = {} (vide) + +== replenishNativesFromPeers (sans résultat) == +N -> N: fetchNativeFromNatives() → aucun natif vivant +N -> IA: GET /opencloud/indexer/natives/1.0 +IA --> N: GetNativesResponse{Natives:[NA,NB]} +note over N: NA et NB connus mais non joignables.\\nAucun nouveau natif trouvé. + +== Fallback : pool d'indexeurs conservé == +note over N: isFallback = true\\nStaticIndexers conservé tel quel [IA, IB]\\n(dernier pool validé avec AdmittedAt != zero)\\nRisque D19 atténué : quorum natif = 0 → fallback accepté + +note over N: Heartbeats IA et IB continuent normalement.\\nPool d'indexeurs opérationnel sans natifs. + +N -> IA: SendHeartbeat /opencloud/heartbeat/1.0 (continue) +N -> IB: SendHeartbeat /opencloud/heartbeat/1.0 (continue) + +== retryLostNative (30s ticker) == +loop toutes les 30s + N -> N: retryLostNative()\\ntente reconnexion NA et NB + N -> NA: dial (échec) + N -> NB: dial (échec) + note over N: Retry sans résultat.\\nPool indexeurs maintenu en fallback. +end + +== Reprise natifs == +NA -> NA: redémarrage +NB -> NB: redémarrage + +N -> NA: dial (succès) +N -> NA: SendHeartbeat /opencloud/heartbeat/1.0 +N -> NB: SendHeartbeat /opencloud/heartbeat/1.0 +note over N: StaticNatives = [NA, NB] restauré\\nisFallback = false + +== Re-consensus pool indexeurs (optionnel) == +par Consensus Phase 1 + N -> NA: /opencloud/native/consensus/1.0\\nConsensusRequest{Candidates:[IA,IB]} + NA --> N: ConsensusResponse{Trusted:[IA,IB]} +else + N -> NB: /opencloud/native/consensus/1.0 + NB --> N: ConsensusResponse{Trusted:[IA,IB]} +end par + +note over N: Pool [IA,IB] reconfirmé.\\nisFallback = false. AdmittedAt[IA,IB] rafraîchi. + +@enduml diff --git a/docs/diagrams/23_failure_native_plus_indexer.puml b/docs/diagrams/23_failure_native_plus_indexer.puml new file mode 100644 index 0000000..5852d10 --- /dev/null +++ b/docs/diagrams/23_failure_native_plus_indexer.puml @@ -0,0 +1,63 @@ +@startuml 23_failure_native_plus_indexer +title F5 — Panne combinée : 1 natif + 1 indexeur + +participant "Node" as N +participant "Indexer A\\n(vivant)" as IA +participant "Indexer B\\n(crashé)" as IB +participant "Native A\\n(vivant)" as NA +participant "Native B\\n(crashé)" as NB + +note over N: Pool nominal : StaticIndexers=[IA,IB], StaticNatives=[NA,NB] + +== Pannes simultanées NB + IB == +NB ->x N: stream reset +IB ->x N: stream reset + +N -> N: AfterDelete(NB) — StaticNatives = [NA] +N -> N: AfterDelete(IB) — StaticIndexers = [IA] + +== Replenish natif (1 vivant) == +N -> N: replenishNativesFromPeers() +N -> NA: GET /opencloud/native/peers/1.0 +NA --> N: GetPeersResponse{Peers:[]} /' NB seul pair, disparu '/ +note over N: Aucun natif alternatif.\\nStaticNatives = [NA] — dégradé. + +== Replenish indexeur depuis NA == +par Fetch pool (timeout 6s) + N -> NA: GET /opencloud/native/indexers/1.0 + NA -> NA: reachableLiveIndexers()\\n(IB absent — heartbeat expiré) + NA --> N: GetIndexersResponse{Indexers:[IA,IC], FillRates:{IA:0.5,IC:0.3}} +end par + +note over N: candidates = [IA, IC] + +par Consensus Phase 1 — 1 seul natif vivant (timeout 3s) + N -> NA: /opencloud/native/consensus/1.0\\nConsensusRequest{Candidates:[IA,IC]} + NA --> N: ConsensusResponse{Trusted:[IA,IC]} +end par + +note over N: IC → 1/1 vote → admis (quorum sur vivants)\\nadmittedAt = time.Now() + +par Phase 2 liveness vote + N -> IA: /opencloud/indexer/consensus/1.0\\nIndexerConsensusRequest{Candidates:[IC]} + IA -> IA: StreamRecords[ProtocolHB][IC]\\nLastSeen ≤ 120s && LastScore ≥ 30 + IA --> N: IndexerConsensusResponse{Alive:[IC]} +end par + +N -> N: replaceStaticIndexers(pool={IA,IC}) +N -> IC: SendHeartbeat /opencloud/heartbeat/1.0 + +note over N: Pool restauré à [IA,IC].\\nMode dégradé : 1 natif seulement.\\nretryLostNative(NB) actif (30s ticker). + +== retryLostNative pour NB == +loop toutes les 30s + N -> NB: dial (échec) +end + +NB -> NB: redémarrage +NB -> NA: heartbeat (mesh natif reconstruit) +N -> NB: dial (succès) +N -> NB: SendHeartbeat /opencloud/heartbeat/1.0 +note over N: StaticNatives = [NA,NB] restauré.\\nMode nominal retrouvé. + +@enduml diff --git a/docs/diagrams/24_failure_retry_lost_native.puml b/docs/diagrams/24_failure_retry_lost_native.puml new file mode 100644 index 0000000..768aa62 --- /dev/null +++ b/docs/diagrams/24_failure_retry_lost_native.puml @@ -0,0 +1,45 @@ +@startuml 24_failure_retry_lost_native +title F6 — retryLostNative : reconnexion natif après panne réseau + +participant "Node / Indexer" as Caller +participant "Native A\\n(vivant)" as NA +participant "Native B\\n(réseau instable)" as NB + +note over Caller: StaticNatives = [NA, NB]\\nHeartbeats actifs vers NA et NB + +== Panne réseau transitoire vers NB == +NB ->x Caller: stream reset (timeout réseau) + +Caller -> Caller: AfterDelete(NB)\\nStaticNatives = [NA]\\nlostNatives.Store(NB.addr) + +== replenishNativesFromPeers — phase 1 == +Caller -> NA: GET /opencloud/native/peers/1.0 +NA --> Caller: GetPeersResponse{Peers:[NB]} + +note over Caller: NB connu de NA, tentative de reconnexion directe + +Caller -> NB: dial (échec — réseau toujours coupé) +note over Caller: Connexion impossible.\\nPassage en retryLostNative() + +== retryLostNative : ticker 30s == +loop toutes les 30s tant que NB absent + Caller -> Caller: retryLostNative()\\nParcourt lostNatives + Caller -> NB: StartNativeRegistration (dial + heartbeat + subscribe) + NB --> Caller: dial échoue + note over Caller: Retry loggé. Prochain essai dans 30s. +end + +== Réseau rétabli == +note over NB: Réseau rétabli\\nNB de nouveau joignable + +Caller -> NB: StartNativeRegistration\\ndial (succès) +Caller -> NB: SendHeartbeat /opencloud/heartbeat/1.0 (goroutine longue durée) +Caller -> NB: /opencloud/native/subscribe/1.0\\nIndexerRegistration{FillRate: fillRateFn()} + +NB --> Caller: subscribe ack + +Caller -> Caller: lostNatives.Delete(NB.addr)\\nStaticNatives = [NA, NB] restauré + +note over Caller: Mode nominal retrouvé.\\nnativeHeartbeatOnce non utilisé (goroutine déjà active pour NA).\\nNouvelle goroutine SendHeartbeat pour NB uniquement. + +@enduml diff --git a/docs/diagrams/25_failure_node_gc.puml b/docs/diagrams/25_failure_node_gc.puml new file mode 100644 index 0000000..e34b738 --- /dev/null +++ b/docs/diagrams/25_failure_node_gc.puml @@ -0,0 +1,42 @@ +@startuml 25_failure_node_gc +title F7 — Crash nœud → GC indexeur + AfterDelete + +participant "Node\\n(crashé)" as N +participant "Indexer A" as IA +participant "Indexer B" as IB +participant "Native A" as NA + +note over N, NA: État nominal : N heartbeatait vers IA et IB + +== Crash Node == +N ->x IA: stream reset (heartbeat coupé) +N ->x IB: stream reset (heartbeat coupé) + +== GC côté Indexer A == +note over IA: HandleHeartbeat : stream reset détecté\\nStreamRecords[ProtocolHB][N].LastSeen figé + +loop ticker GC (30s) — StartGC(30*time.Second) + IA -> IA: gc()\\nnow.After(Expiry) où Expiry = lastHBTime + 2min\\n→ si 2min sans heartbeat → éviction + IA -> IA: delete(StreamRecords[ProtocolHB][N])\\nAfterDelete(N, name, did) appelé hors lock + note over IA: N retiré du registre vivant.\\nFillRate recalculé (n-1 / maxNodes). +end + +== Impact sur le scoring / fill rate == +note over IA: FillRate diminue\\nProchain subscribe vers NA inclura FillRate mis à jour + +IA -> NA: /opencloud/native/subscribe/1.0\\nIndexerRegistration{FillRate: 0.3} /' était 0.5 '/ + +NA -> NA: liveIndexerEntry[IA].FillRate = 0.3\\nPriorité de routage recalculée : w(0.3) = 0.21 + +== Impact sur la Phase 2 (indexerLivenessVote) == +note over IA: Si un autre nœud demande consensus,\\nN n'est plus dans StreamRecords.\\nN absent de la réponse Alive[]. + +note over IB: Même GC effectué côté IB.\\nN retiré de StreamRecords[ProtocolHB]. + +== Reconnexion éventuelle du nœud == +N -> N: redémarrage +N -> IA: SendHeartbeat /opencloud/heartbeat/1.0\\nHeartbeat{Score: X, IndexersBinded: 2} +IA -> IA: HandleHeartbeat → nouveau UptimeTracker(FirstSeen=now)\\nStreamRecords[ProtocolHB][N] recréé +note over IA: N de retour avec FirstSeen frais.\\ndynamicMinScore élevé tant que age < 24h. + +@enduml diff --git a/docs/diagrams/README.md b/docs/diagrams/README.md index 7a8b391..cc120fb 100644 --- a/docs/diagrams/README.md +++ b/docs/diagrams/README.md @@ -1,39 +1,71 @@ -# OC-Discovery — Diagrammes de séquence +# OC-Discovery — Diagrammes d'architecture et de séquence -Tous les fichiers `.mmd` sont au format [Mermaid](https://mermaid.js.org/). -Rendu possible via VS Code (extension Mermaid Preview), IntelliJ, ou [mermaid.live](https://mermaid.live). +Tous les fichiers sont au format [PlantUML](https://plantuml.com/). +Rendu possible via VS Code (extension PlantUML), IntelliJ, ou [plantuml.com/plantuml](https://www.plantuml.com/plantuml/uml/). -## Vue d'ensemble des diagrammes +## Diagrammes de séquence (flux internes) | Fichier | Description | |---------|-------------| -| `01_node_init.mmd` | Initialisation complète d'un Node (libp2p host, GossipSub, indexers, StreamService, PubSubService, NATS) | -| `02_node_claim.mmd` | Enregistrement du nœud auprès des indexeurs (`claimInfo` + `publishPeerRecord`) | -| `03_indexer_heartbeat.mmd` | Protocole heartbeat avec calcul du score qualité (bande passante, uptime, diversité) | -| `04_indexer_publish.mmd` | Publication d'un `PeerRecord` vers l'indexeur → DHT | -| `05_indexer_get.mmd` | Résolution d'un pair via l'indexeur (`GetPeerRecord` + `handleNodeGet` + DHT) | -| `06_native_registration.mmd` | Enregistrement d'un indexeur auprès d'un Native Indexer + gossip PubSub | -| `07_native_get_consensus.mmd` | `ConnectToNatives` : pool d'indexeurs + protocole de consensus (vote majoritaire) | -| `08_nats_create_resource.mmd` | Handler NATS `CREATE_RESOURCE` : connexion/déconnexion d'un partner | -| `09_nats_propagation.mmd` | Handler NATS `PROPALGATION_EVENT` : delete, considers, planner, search | -| `10_pubsub_search.mmd` | Recherche gossip globale (type `"all"`) via GossipSub | -| `11_stream_search.mmd` | Recherche directe par stream (type `"known"` ou `"partner"`) | -| `12_partner_heartbeat.mmd` | Heartbeat partner + propagation CRUD vers les partenaires | -| `13_planner_flow.mmd` | Session planner (ouverture, échange, fermeture) | -| `14_native_offload_gc.mmd` | Boucles background du Native Indexer (offload, DHT refresh, GC) | +| `01_node_init.puml` | Initialisation complète d'un Node (libp2p host, GossipSub, indexers, StreamService, PubSubService, NATS) | +| `02_node_claim.puml` | Enregistrement du nœud auprès des indexeurs (`claimInfo` + `publishPeerRecord`) | +| `03_indexer_heartbeat.puml` | Protocole heartbeat avec score 5 composants (U/B/D/L/F), UptimeTracker, dynamicMinScore | +| `04_indexer_publish.puml` | Publication d'un `PeerRecord` vers l'indexeur → DHT | +| `05_indexer_get.puml` | Résolution d'un pair via l'indexeur (`GetPeerRecord` + `handleNodeGet` + DHT) | +| `06_native_registration.puml` | Enregistrement d'un indexeur auprès du Native (FillRate, signature, TTL 90s, unsubscribe) | +| `07_native_get_consensus.puml` | `ConnectToNatives` : fetch pool + Phase 1 (clientSideConsensus) + Phase 2 (indexerLivenessVote) | +| `08_nats_create_resource.puml` | Handler NATS `CREATE_RESOURCE` : connexion/déconnexion d'un partner | +| `09_nats_propagation.puml` | Handler NATS `PROPALGATION_EVENT` : delete, considers, planner, search | +| `10_pubsub_search.puml` | Recherche gossip globale (type `"all"`) via GossipSub | +| `11_stream_search.puml` | Recherche directe par stream (type `"known"` ou `"partner"`) | +| `12_partner_heartbeat.puml` | Heartbeat partner + propagation CRUD vers les partenaires | +| `13_planner_flow.puml` | Session planner (ouverture, échange, fermeture) | +| `14_native_offload_gc.puml` | Boucles background du Native Indexer (offload, DHT refresh, GC) | -## Protocoles libp2p utilisés +## Diagrammes de topologie et flux de panne + +### Configurations réseau + +| Fichier | Description | +|---------|-------------| +| `15_archi_config_nominale.puml` | C1 — Topologie nominale : 2 natifs · 2 indexeurs · 2 nœuds, tous flux | +| `16_archi_config_seed.puml` | C2 — Mode seed sans natif : indexeurs à AdmittedAt=0, risque D20 actif | + +### Flux de démarrage + +| Fichier | Description | +|---------|-------------| +| `17_startup_consensus_phase1_phase2.puml` | Démarrage nominal : Phase 1 (admission native) + Phase 2 (liveness vote) | +| `18_startup_seed_discovers_native.puml` | Upgrade seed → nominal : goroutine async découvre un natif via l'indexeur | + +### Flux de panne + +| Fichier | Code | Description | +|---------|------|-------------| +| `19_failure_indexer_crash.puml` | F1 | Panne 1 indexeur → replenish depuis natif → IC admis | +| `20_failure_both_indexers_selfdelegate.puml` | F2 | Panne 2 indexeurs → natif `IsSelfFallback=true`, runOffloadLoop | +| `21_failure_native_one_down.puml` | F3 | Panne 1 natif → quorum 1/1 suffisant, mode dégradé | +| `22_failure_both_natives.puml` | F4 | Panne 2 natifs → fallback pool pré-validé, retryLostNative | +| `23_failure_native_plus_indexer.puml` | F5 | Panne combinée : 1 natif + 1 indexeur → double replenish | +| `24_failure_retry_lost_native.puml` | F6 | Panne réseau transitoire → retryLostNative (30s ticker) | +| `25_failure_node_gc.puml` | F7 | Crash nœud → GC indexeur (120s), AfterDelete, fill rate recalculé | + +## Protocoles libp2p utilisés (référence complète) | Protocole | Description | |-----------|-------------| -| `/opencloud/heartbeat/1.0` | Heartbeat node → indexeur (long-lived) | -| `/opencloud/heartbeat/indexer/1.0` | Heartbeat indexeur → native (long-lived) | +| `/opencloud/heartbeat/1.0` | Heartbeat universel : node→indexeur, indexeur→native, native→native (long-lived) | +| `/opencloud/probe/1.0` | Sonde de bande passante (echo, mesure latence + débit) | | `/opencloud/resource/heartbeat/partner/1.0` | Heartbeat node ↔ partner (long-lived) | | `/opencloud/record/publish/1.0` | Publication `PeerRecord` vers indexeur | | `/opencloud/record/get/1.0` | Requête `GetPeerRecord` vers indexeur | -| `/opencloud/native/subscribe/1.0` | Enregistrement indexeur auprès du native | -| `/opencloud/native/indexers/1.0` | Requête de pool d'indexeurs au native | -| `/opencloud/native/consensus/1.0` | Validation de pool d'indexeurs (consensus) | +| `/opencloud/native/subscribe/1.0` | Enregistrement indexeur auprès du native (+ FillRate) | +| `/opencloud/native/unsubscribe/1.0` | Désenregistrement explicite indexeur → native | +| `/opencloud/native/indexers/1.0` | Requête de pool d'indexeurs au native (tri par w(F)=F×(1-F)) | +| `/opencloud/native/consensus/1.0` | Phase 1 : validation de pool d'indexeurs (vote majoritaire natifs) | +| `/opencloud/native/peers/1.0` | Demande de pairs natifs connus (replenish mesh natif) | +| `/opencloud/indexer/natives/1.0` | Demande d'adresses de natifs connus par un indexeur | +| `/opencloud/indexer/consensus/1.0` | Phase 2 : liveness vote (LastSeen ≤ 120s && LastScore ≥ 30) | | `/opencloud/resource/search/1.0` | Recherche de ressources entre peers | | `/opencloud/resource/create/1.0` | Propagation création ressource vers partner | | `/opencloud/resource/update/1.0` | Propagation mise à jour ressource vers partner | diff --git a/main.go b/main.go index 6354b77..26c5b39 100644 --- a/main.go +++ b/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log" "oc-discovery/conf" "oc-discovery/daemons/node" @@ -43,7 +42,6 @@ func main() { syscall.SIGTERM, ) defer stop() - fmt.Println(conf.GetConfig().NodeMode) isNode := strings.Contains(conf.GetConfig().NodeMode, "node") isIndexer := strings.Contains(conf.GetConfig().NodeMode, "indexer") isNativeIndexer := strings.Contains(conf.GetConfig().NodeMode, "native-indexer")