Skip to content

Commit 6ddee85

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(p2p): edgevpn generic-channel publisher and handler for affinity sync
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent c69f136 commit 6ddee85

3 files changed

Lines changed: 130 additions & 0 deletions

File tree

core/p2p/affinity_sync.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package p2p
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"time"
7+
8+
"github.com/mudler/LocalAI/core/services/messaging"
9+
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
10+
"github.com/mudler/edgevpn/pkg/blockchain"
11+
"github.com/mudler/edgevpn/pkg/hub"
12+
"github.com/mudler/edgevpn/pkg/node"
13+
"github.com/mudler/xlog"
14+
)
15+
16+
// affinitySubjectKey is the hub.Message annotation carrying the logical subject
17+
// (observe vs invalidate) so the receiver can dispatch the way a NATS subject
18+
// would. The generic channel has no subject routing, so we carry it ourselves.
19+
const affinitySubjectKey = "subject"
20+
21+
// genericChannelPublisher adapts an edgevpn node's generic broadcast channel to
22+
// the prefixcache publisher interface (Publish(subject, v)). It lets a
23+
// federation server reuse prefixcache.Sync for cross-server affinity coherence
24+
// without NATS: each event is JSON-encoded into a hub.Message and broadcast over
25+
// the generic channel (not the slow blockchain ledger).
26+
type genericChannelPublisher struct {
27+
node *node.Node
28+
}
29+
30+
// Publish satisfies prefixcache's (unexported) publisher interface structurally.
31+
func (p *genericChannelPublisher) Publish(subject string, v any) error {
32+
payload, err := json.Marshal(v)
33+
if err != nil {
34+
return fmt.Errorf("marshalling affinity event: %w", err)
35+
}
36+
return p.node.PublishMessage(&hub.Message{
37+
Message: string(payload),
38+
Annotations: map[string]interface{}{affinitySubjectKey: subject},
39+
})
40+
}
41+
42+
// applyAffinityMessage decodes a generic-channel affinity message and applies it
43+
// to sync WITHOUT re-broadcasting (ApplyObserve/ApplyInvalidate). now is the
44+
// local clock so TTL is measured per server. Unknown subjects, malformed
45+
// payloads, and nil inputs are ignored (debug-logged), never fatal.
46+
func applyAffinityMessage(sync *prefixcache.Sync, m *hub.Message, now time.Time) {
47+
if sync == nil || m == nil {
48+
return
49+
}
50+
subject, _ := m.Annotations[affinitySubjectKey].(string)
51+
switch subject {
52+
case messaging.SubjectPrefixCacheObserve:
53+
var ev messaging.PrefixCacheObserveEvent
54+
if err := json.Unmarshal([]byte(m.Message), &ev); err != nil {
55+
xlog.Debug("affinity: bad observe payload", "error", err)
56+
return
57+
}
58+
sync.ApplyObserve(ev, now)
59+
case messaging.SubjectPrefixCacheInvalidate:
60+
var ev messaging.PrefixCacheInvalidateEvent
61+
if err := json.Unmarshal([]byte(m.Message), &ev); err != nil {
62+
xlog.Debug("affinity: bad invalidate payload", "error", err)
63+
return
64+
}
65+
sync.ApplyInvalidate(ev)
66+
default:
67+
// Other generic-channel traffic; not ours.
68+
}
69+
}
70+
71+
// affinityHandler returns the edgevpn generic-channel handler that applies remote
72+
// affinity events to this server's index. It is registered at node construction
73+
// (handlers cannot be added after Start) and reads fs.prefixSync lazily, which is
74+
// safe because messages only arrive after Start, by which point Start has wired
75+
// fs.prefixSync.
76+
func (fs *FederatedServer) affinityHandler() node.Handler {
77+
return func(_ *blockchain.Ledger, m *hub.Message, _ chan *hub.Message) error {
78+
applyAffinityMessage(fs.prefixSync, m, time.Now())
79+
return nil
80+
}
81+
}

core/p2p/affinity_sync_test.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package p2p
2+
3+
import (
4+
"encoding/json"
5+
"time"
6+
7+
. "github.com/onsi/ginkgo/v2"
8+
. "github.com/onsi/gomega"
9+
10+
"github.com/mudler/LocalAI/core/services/messaging"
11+
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
12+
"github.com/mudler/edgevpn/pkg/hub"
13+
)
14+
15+
var _ = Describe("applyAffinityMessage", func() {
16+
ref := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
17+
18+
observeMsg := func(ev messaging.PrefixCacheObserveEvent) *hub.Message {
19+
payload, _ := json.Marshal(ev)
20+
return &hub.Message{
21+
Message: string(payload),
22+
Annotations: map[string]interface{}{affinitySubjectKey: messaging.SubjectPrefixCacheObserve},
23+
}
24+
}
25+
26+
It("applies a peer observe so the local index resolves the warm peer", func() {
27+
cfg := prefixcache.DefaultConfig()
28+
idx := prefixcache.NewIndex(cfg)
29+
sync := prefixcache.NewSync(idx, nil)
30+
chain := prefixcache.ExtractChain("m1", "a fairly long shared system prompt body for the prefix chain", cfg)
31+
32+
applyAffinityMessage(sync, observeMsg(messaging.PrefixCacheObserveEvent{Model: "m1", Chain: chain, NodeID: "warm", Replica: 0}), ref)
33+
34+
d := idx.Decide("m1", chain, []prefixcache.ReplicaKey{{NodeID: "warm"}, {NodeID: "cold"}}, ref)
35+
Expect(d.HasHot).To(BeTrue())
36+
Expect(d.Hot.NodeID).To(Equal("warm"))
37+
})
38+
39+
It("ignores malformed, unknown-subject, and nil inputs without panicking", func() {
40+
sync := prefixcache.NewSync(prefixcache.NewIndex(prefixcache.DefaultConfig()), nil)
41+
applyAffinityMessage(sync, &hub.Message{Message: "not-json", Annotations: map[string]interface{}{affinitySubjectKey: messaging.SubjectPrefixCacheObserve}}, ref)
42+
applyAffinityMessage(sync, &hub.Message{Message: "{}", Annotations: map[string]interface{}{affinitySubjectKey: "some.other.subject"}}, ref)
43+
applyAffinityMessage(sync, &hub.Message{Message: "{}"}, ref)
44+
applyAffinityMessage(nil, observeMsg(messaging.PrefixCacheObserveEvent{Model: "m"}), ref)
45+
applyAffinityMessage(sync, nil, ref)
46+
Expect(true).To(BeTrue())
47+
})
48+
})

core/p2p/federated.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ type FederatedServer struct {
3232
bodyLimit int64 // max request body bytes (0 = unlimited)
3333
prefixCfg prefixcache.Config
3434
prefixIndex *prefixcache.Index
35+
prefixSync *prefixcache.Sync
3536
}
3637

3738
func NewFederatedServer(listenAddr, service, p2pToken string, loadBalanced bool, workerTarget string, bodyLimit int64) *FederatedServer {

0 commit comments

Comments
 (0)