This commit is contained in:
mr
2026-03-11 19:29:39 +01:00
parent d0af40f4c7
commit 780a0c530d
13 changed files with 51 additions and 997 deletions

View File

@@ -5,7 +5,6 @@ import (
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"math/rand"
"oc-discovery/daemons/node/common"
@@ -94,8 +93,6 @@ func (pr *PeerRecord) ExtractPeer(ourkey string, key string, pubKey crypto.PubKe
type GetValue struct {
Key string `json:"key"`
PeerID string `json:"peer_id,omitempty"`
Name string `json:"name,omitempty"`
Search bool `json:"search,omitempty"`
}
type GetResponse struct {
@@ -107,10 +104,6 @@ func (ix *IndexerService) genKey(did string) string {
return "/node/" + did
}
func (ix *IndexerService) genNameKey(name string) string {
return "/name/" + name
}
func (ix *IndexerService) genPIDKey(peerID string) string {
return "/pid/" + peerID
}
@@ -163,16 +156,10 @@ func (ix *IndexerService) initNodeHandler() {
return
}
cancel()
ix.publishNameEvent(NameIndexAdd, rec.Name, rec.PeerID, rec.DID)
if rec.Name != "" {
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
ix.DHT.PutValue(ctx2, ix.genNameKey(rec.Name), []byte(rec.DID))
cancel2()
}
if rec.PeerID != "" {
ctx3, cancel3 := context.WithTimeout(context.Background(), 10*time.Second)
ix.DHT.PutValue(ctx3, ix.genPIDKey(rec.PeerID), []byte(rec.DID))
cancel3()
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
ix.DHT.PutValue(ctx2, ix.genPIDKey(rec.PeerID), []byte(rec.DID))
cancel2()
}
}
ix.Host.SetStreamHandler(common.ProtocolHeartbeat, ix.HandleHeartbeat)
@@ -277,24 +264,13 @@ func (ix *IndexerService) handleNodePublish(s network.Stream) {
}
cancel()
fmt.Println("publishNameEvent")
ix.publishNameEvent(NameIndexAdd, rec.Name, rec.PeerID, rec.DID)
// Secondary index: /name/<name> → DID, so peers can resolve by human-readable name.
if rec.Name != "" {
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
if err := ix.DHT.PutValue(ctx2, ix.genNameKey(rec.Name), []byte(rec.DID)); err != nil {
logger.Err(err).Str("name", rec.Name).Msg("indexer: failed to write name index")
}
cancel2()
}
// Secondary index: /pid/<peerID> → DID, so peers can resolve by libp2p PeerID.
if rec.PeerID != "" {
ctx3, cancel3 := context.WithTimeout(context.Background(), 10*time.Second)
if err := ix.DHT.PutValue(ctx3, ix.genPIDKey(rec.PeerID), []byte(rec.DID)); err != nil {
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
if err := ix.DHT.PutValue(ctx2, ix.genPIDKey(rec.PeerID), []byte(rec.DID)); err != nil {
logger.Err(err).Str("pid", rec.PeerID).Msg("indexer: failed to write pid index")
}
cancel3()
cancel2()
}
return
}
@@ -324,52 +300,30 @@ func (ix *IndexerService) handleNodeGet(s network.Stream) {
resp := GetResponse{Found: false, Records: map[string]PeerRecord{}}
fmt.Println("handleNodeGet", req.Search, req.Name)
keys := []string{}
// Name substring search — scan in-memory connected nodes first, then DHT exact match.
if req.Name != "" {
if req.Search {
for _, did := range ix.LookupNameIndex(strings.ToLower(req.Name)) {
keys = append(keys, did)
}
} else {
// 2. DHT exact-name lookup: covers nodes that published but aren't currently connected.
nameCtx, nameCancel := context.WithTimeout(context.Background(), 5*time.Second)
if ch, err := ix.DHT.SearchValue(nameCtx, ix.genNameKey(req.Name)); err == nil {
for did := range ch {
keys = append(keys, string(did))
break
}
}
nameCancel()
}
} else if req.PeerID != "" {
// Resolve DID key: by PeerID (secondary /pid/ index) or direct DID key.
var key string
if req.PeerID != "" {
pidCtx, pidCancel := context.WithTimeout(context.Background(), 5*time.Second)
if did, err := ix.DHT.GetValue(pidCtx, ix.genPIDKey(req.PeerID)); err == nil {
keys = append(keys, string(did))
}
did, err := ix.DHT.GetValue(pidCtx, ix.genPIDKey(req.PeerID))
pidCancel()
if err == nil {
key = string(did)
}
} else {
keys = append(keys, req.Key)
key = req.Key
}
// DHT record fetch by DID key (covers exact-name and PeerID paths).
if len(keys) > 0 {
for _, k := range keys {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
fmt.Println("TRY TO CATCH DID", ix.genKey(k))
c, err := ix.DHT.GetValue(ctx, ix.genKey(k))
cancel()
fmt.Println("TRY TO CATCH DID ERR", ix.genKey(k), c, err)
if err == nil {
var rec PeerRecord
if json.Unmarshal(c, &rec) == nil {
fmt.Println("CATCH DID ERR", ix.genKey(k), rec)
resp.Records[rec.PeerID] = rec
}
} else if req.Name == "" && req.PeerID == "" {
logger.Err(err).Msg("Failed to fetch PeerRecord from DHT " + req.Key)
if key != "" {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
c, err := ix.DHT.GetValue(ctx, ix.genKey(key))
cancel()
if err == nil {
var rec PeerRecord
if json.Unmarshal(c, &rec) == nil {
resp.Records[rec.PeerID] = rec
}
} else {
logger.Err(err).Msg("Failed to fetch PeerRecord from DHT " + key)
}
}

View File

@@ -1,320 +0,0 @@
package indexer
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
"oc-discovery/daemons/node/common"
oclib "cloud.o-forge.io/core/oc-lib"
pubsub "github.com/libp2p/go-libp2p-pubsub"
pp "github.com/libp2p/go-libp2p/core/peer"
)
// TopicNameIndex is the GossipSub topic shared by regular indexers to exchange
// add/delete events for the distributed name→peerID mapping.
const TopicNameIndex = "oc-name-index"
// nameIndexDedupWindow suppresses re-emission of the same (action, name, peerID)
// tuple within this window, reducing duplicate events when a node is registered
// with multiple indexers simultaneously.
const nameIndexDedupWindow = 30 * time.Second
// NameIndexAction indicates whether a name mapping is being added or removed.
type NameIndexAction string
const (
NameIndexAdd NameIndexAction = "add"
NameIndexDelete NameIndexAction = "delete"
)
// NameIndexEvent is published on TopicNameIndex by each indexer when a node
// registers (add) or is evicted by the GC (delete).
type NameIndexEvent struct {
Action NameIndexAction `json:"action"`
Name string `json:"name"`
PeerID string `json:"peer_id"`
DID string `json:"did"`
}
// nameIndexState holds the local in-memory name index and the sender-side
// deduplication tracker.
//
// Search strategy: trigram inverted index.
// - byName: lowercased name → peerID → DID (for delete and exact resolution)
// - byPeer: peerID → lowercased name (to recompute trigrams on delete)
// - trigrams: 3-char substring → set of peerIDs (for O(1) substring lookup)
//
// For needles shorter than 3 chars the trigram index cannot help; a linear
// scan of byName is used as fallback (rare and fast enough at small N).
type nameIndexState struct {
byName map[string]map[string]string // name → peerID → DID
byPeer map[string]string // peerID → name
trigrams map[string]map[string]struct{} // trigram → peerID set
indexMu sync.RWMutex
// emitted deduplicates GossipSub emissions within nameIndexDedupWindow.
// Purged periodically to prevent unbounded growth.
emitted map[string]time.Time
emittedMu sync.Mutex
}
// trigramsOf returns all overlapping 3-char substrings of s (already lowercased).
// If s is shorter than 3 chars the string itself is returned as the sole token.
func trigramsOf(s string) []string {
if len(s) < 3 {
return []string{s}
}
out := make([]string, 0, len(s)-2)
for i := 0; i <= len(s)-3; i++ {
out = append(out, s[i:i+3])
}
return out
}
// addTrigrams inserts peerID into every trigram bucket for name.
func (s *nameIndexState) addTrigrams(name, peerID string) {
for _, tg := range trigramsOf(name) {
if s.trigrams[tg] == nil {
s.trigrams[tg] = map[string]struct{}{}
}
s.trigrams[tg][peerID] = struct{}{}
}
}
// removeTrigrams deletes peerID from every trigram bucket for name,
// cleaning up empty buckets to keep memory tight.
func (s *nameIndexState) removeTrigrams(name, peerID string) {
for _, tg := range trigramsOf(name) {
if m := s.trigrams[tg]; m != nil {
delete(m, peerID)
if len(m) == 0 {
delete(s.trigrams, tg)
}
}
}
}
// shouldEmit returns true if the (action, name, peerID) tuple has not been
// emitted within nameIndexDedupWindow, updating the tracker if so.
//
// On DELETE: the ADD entry for the same peer is immediately removed — the peer
// is gone, keeping it would cause the map to grow with departed peers forever.
// The DELETE entry itself is kept for the dedup window to absorb duplicate
// delete events, then cleaned by the purgeEmitted ticker.
func (s *nameIndexState) shouldEmit(action NameIndexAction, name, peerID string) bool {
key := string(action) + ":" + name + ":" + peerID
s.emittedMu.Lock()
defer s.emittedMu.Unlock()
if t, ok := s.emitted[key]; ok && time.Since(t) < nameIndexDedupWindow {
return false
}
s.emitted[key] = time.Now()
if action == NameIndexDelete {
// Peer is leaving: drop its ADD entry — no longer needed.
delete(s.emitted, string(NameIndexAdd)+":"+name+":"+peerID)
}
return true
}
// purgeEmitted removes stale DELETE entries from the emitted dedup map.
// ADD entries are cleaned eagerly on DELETE, so only short-lived DELETE
// entries remain here; the ticker just trims those stragglers.
func (s *nameIndexState) purgeEmitted() {
now := time.Now()
s.emittedMu.Lock()
defer s.emittedMu.Unlock()
for k, t := range s.emitted {
if now.Sub(t) >= nameIndexDedupWindow {
delete(s.emitted, k)
}
}
}
// onEvent applies a received NameIndexEvent to the local index.
// "add" inserts/updates the mapping; "delete" removes it.
// Operations are idempotent — duplicate events from multiple indexers are harmless.
func (s *nameIndexState) onEvent(evt NameIndexEvent) {
if evt.Name == "" || evt.PeerID == "" {
return
}
nameLow := strings.ToLower(evt.Name)
s.indexMu.Lock()
defer s.indexMu.Unlock()
switch evt.Action {
case NameIndexAdd:
// If the peer previously had a different name, clean up old trigrams.
if old, ok := s.byPeer[evt.PeerID]; ok && old != nameLow {
s.removeTrigrams(old, evt.PeerID)
if s.byName[old] != nil {
delete(s.byName[old], evt.PeerID)
if len(s.byName[old]) == 0 {
delete(s.byName, old)
}
}
}
if s.byName[nameLow] == nil {
s.byName[nameLow] = map[string]string{}
}
s.byName[nameLow][evt.PeerID] = evt.DID
s.byPeer[evt.PeerID] = nameLow
s.addTrigrams(nameLow, evt.PeerID)
case NameIndexDelete:
// Use stored name so trigrams match exactly what was indexed.
name := nameLow
if stored, ok := s.byPeer[evt.PeerID]; ok {
name = stored
}
s.removeTrigrams(name, evt.PeerID)
delete(s.byPeer, evt.PeerID)
if s.byName[name] != nil {
delete(s.byName[name], evt.PeerID)
if len(s.byName[name]) == 0 {
delete(s.byName, name)
}
}
}
}
// initNameIndex joins TopicNameIndex and starts consuming events.
// Must be called after ix.PS is ready.
func (ix *IndexerService) initNameIndex(ps *pubsub.PubSub) {
logger := oclib.GetLogger()
state := &nameIndexState{
byName: map[string]map[string]string{},
byPeer: map[string]string{},
trigrams: map[string]map[string]struct{}{},
emitted: map[string]time.Time{},
}
ix.nameIndex = state
// Periodically purge the emitted dedup map so it doesn't grow forever.
go func() {
t := time.NewTicker(nameIndexDedupWindow)
defer t.Stop()
for range t.C {
state.purgeEmitted()
}
}()
ps.RegisterTopicValidator(TopicNameIndex, func(_ context.Context, _ pp.ID, _ *pubsub.Message) bool {
return true
})
topic, err := ps.Join(TopicNameIndex)
if err != nil {
logger.Err(err).Msg("name index: failed to join topic")
return
}
ix.LongLivedStreamRecordedService.LongLivedPubSubService.PubsubMu.Lock()
ix.LongLivedStreamRecordedService.LongLivedPubSubService.LongLivedPubSubs[TopicNameIndex] = topic
ix.LongLivedStreamRecordedService.LongLivedPubSubService.PubsubMu.Unlock()
common.SubscribeEvents(
ix.LongLivedStreamRecordedService.LongLivedPubSubService,
context.Background(),
TopicNameIndex,
-1,
func(_ context.Context, evt NameIndexEvent, _ string) {
ix.nameIndex.onEvent(evt)
},
)
}
// publishNameEvent emits a NameIndexEvent on TopicNameIndex, subject to the
// sender-side deduplication window.
func (ix *IndexerService) publishNameEvent(action NameIndexAction, name, peerID, did string) {
if ix.nameIndex == nil || name == "" || peerID == "" {
return
}
if !ix.nameIndex.shouldEmit(action, name, peerID) {
return
}
ix.LongLivedStreamRecordedService.LongLivedPubSubService.PubsubMu.RLock()
topic := ix.LongLivedStreamRecordedService.LongLivedPubSubService.LongLivedPubSubs[TopicNameIndex]
ix.LongLivedStreamRecordedService.LongLivedPubSubService.PubsubMu.RUnlock()
if topic == nil {
return
}
evt := NameIndexEvent{Action: action, Name: name, PeerID: peerID, DID: did}
b, err := json.Marshal(evt)
if err != nil {
return
}
_ = topic.Publish(context.Background(), b)
}
// LookupNameIndex searches the distributed name index for peers whose name
// contains needle (case-insensitive). Returns peerID → DID for matched peers.
// Returns nil if the name index is not initialised.
//
// Algorithm:
// - needle ≥ 3 chars: trigram intersection → O(|candidates|) verify pass.
// The trigram index immediately narrows the candidate set; false positives
// are eliminated by the full-string contains check.
// - needle < 3 chars: linear scan of byName (rare, still fast at small N).
func (ix *IndexerService) LookupNameIndex(needle string) map[string]string {
if ix.nameIndex == nil {
return nil
}
needleLow := strings.ToLower(needle)
result := map[string]string{}
ix.nameIndex.indexMu.RLock()
defer ix.nameIndex.indexMu.RUnlock()
if len(needleLow) < 3 {
// Short needle: linear scan fallback.
for name, peers := range ix.nameIndex.byName {
if strings.Contains(name, needleLow) {
for peerID, did := range peers {
result[peerID] = did
}
}
}
return result
}
// Trigram intersection: start with the first trigram's set, then
// progressively intersect with each subsequent trigram's set.
tgs := trigramsOf(needleLow)
var candidates map[string]struct{}
for _, tg := range tgs {
set := ix.nameIndex.trigrams[tg]
if len(set) == 0 {
return result // any empty trigram set → no possible match
}
if candidates == nil {
candidates = make(map[string]struct{}, len(set))
for pid := range set {
candidates[pid] = struct{}{}
}
} else {
for pid := range candidates {
if _, ok := set[pid]; !ok {
delete(candidates, pid)
}
}
}
if len(candidates) == 0 {
return result
}
}
// Full-string verification pass: trigrams admit false positives
// (e.g. "abc" and "bca" share the trigram "bc_" with a rotated name).
for peerID := range candidates {
name := ix.nameIndex.byPeer[peerID]
if strings.Contains(name, needleLow) {
did := ""
if m := ix.nameIndex.byName[name]; m != nil {
did = m[peerID]
}
result[peerID] = did
}
}
return result
}

View File

@@ -48,7 +48,6 @@ type IndexerService struct {
DHT *dht.IpfsDHT
isStrictIndexer bool
mu sync.RWMutex
nameIndex *nameIndexState
dhtProvideCancel context.CancelFunc
bornAt time.Time
// Passive DHT cache: refreshed every 2 min in background, used for suggestions.
@@ -99,10 +98,7 @@ func NewIndexerService(h host.Host, ps *pubsub.PubSub, maxNode int) *IndexerServ
go ix.SubscribeToSearch(ix.PS, nil)
}
logger.Info().Msg("init distributed name index...")
ix.initNameIndex(ps)
ix.LongLivedStreamRecordedService.AfterDelete = func(pid pp.ID, name, did string) {
ix.publishNameEvent(NameIndexDelete, name, pid.String(), did)
// Remove behavior state for peers that are no longer connected and
// have no active ban — keeps memory bounded to the live node set.
ix.behavior.Cleanup(pid)
@@ -489,9 +485,6 @@ func (ix *IndexerService) Close() {
}
ix.DHT.Close()
ix.PS.UnregisterTopicValidator(common.TopicPubSubSearch)
if ix.nameIndex != nil {
ix.PS.UnregisterTopicValidator(TopicNameIndex)
}
for _, s := range ix.StreamRecords {
for _, ss := range s {
ss.HeartbeatStream.Stream.Close()