Skip to content

Commit 849a4a6

Browse files
mudlerlocalai-org-maint-bot
authored andcommitted
feat(p2p): optional cross-server prefix-affinity sync over the generic channel
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 6ddee85 commit 849a4a6

5 files changed

Lines changed: 72 additions & 18 deletions

File tree

core/cli/federated.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@ type FederatedCLI struct {
1515
Peer2PeerNetworkID string `env:"LOCALAI_P2P_NETWORK_ID,P2P_NETWORK_ID" help:"Network ID for P2P mode, can be set arbitrarly by the user for grouping a set of instances." group:"p2p"`
1616
TargetWorker string `env:"LOCALAI_TARGET_WORKER,TARGET_WORKER" help:"Target worker to run the federated server on" group:"p2p"`
1717
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-size limit in megabytes" group:"api"`
18+
AffinitySync bool `env:"LOCALAI_FEDERATED_AFFINITY_SYNC,FEDERATED_AFFINITY_SYNC" default:"false" help:"Broadcast prefix-cache affinity observations to other federation servers over the p2p generic channel (enable on every federation server that should cohere)" group:"p2p"`
1819
}
1920

2021
func (f *FederatedCLI) Run(ctx *cliContext.Context) error {
2122
warnDeprecatedFlags()
2223

23-
fs := p2p.NewFederatedServer(f.Address, p2p.NetworkID(f.Peer2PeerNetworkID, p2p.FederatedID), f.Peer2PeerToken, !f.RandomWorker, f.TargetWorker, int64(f.UploadLimit)*1024*1024)
24+
fs := p2p.NewFederatedServer(f.Address, p2p.NetworkID(f.Peer2PeerNetworkID, p2p.FederatedID), f.Peer2PeerToken, !f.RandomWorker, f.TargetWorker, int64(f.UploadLimit)*1024*1024, f.AffinitySync)
2425

2526
c, cancel := context.WithCancel(context.Background())
2627

core/p2p/federated.go

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package p2p
22

33
import (
4+
"context"
45
"encoding/json"
56
"fmt"
67
"math/rand/v2"
@@ -33,20 +34,25 @@ type FederatedServer struct {
3334
prefixCfg prefixcache.Config
3435
prefixIndex *prefixcache.Index
3536
prefixSync *prefixcache.Sync
37+
prefixProvider prefixcache.Provider // Index (sync off) or Sync (sync on)
38+
syncAffinity bool
3639
}
3740

38-
func NewFederatedServer(listenAddr, service, p2pToken string, loadBalanced bool, workerTarget string, bodyLimit int64) *FederatedServer {
41+
func NewFederatedServer(listenAddr, service, p2pToken string, loadBalanced bool, workerTarget string, bodyLimit int64, syncAffinity bool) *FederatedServer {
3942
cfg := prefixcache.DefaultConfig()
43+
idx := prefixcache.NewIndex(cfg)
4044
return &FederatedServer{
41-
listenAddr: listenAddr,
42-
service: service,
43-
p2ptoken: p2pToken,
44-
requestTable: map[string]int{},
45-
loadBalanced: loadBalanced,
46-
workerTarget: workerTarget,
47-
bodyLimit: bodyLimit,
48-
prefixCfg: cfg,
49-
prefixIndex: prefixcache.NewIndex(cfg),
45+
listenAddr: listenAddr,
46+
service: service,
47+
p2ptoken: p2pToken,
48+
requestTable: map[string]int{},
49+
loadBalanced: loadBalanced,
50+
workerTarget: workerTarget,
51+
bodyLimit: bodyLimit,
52+
prefixCfg: cfg,
53+
prefixIndex: idx,
54+
prefixProvider: idx,
55+
syncAffinity: syncAffinity,
5056
}
5157
}
5258

@@ -151,7 +157,7 @@ func extractModel(queryModel string, body []byte) string {
151157
// chain, or "" when there is no match strong enough among the candidates. It
152158
// reuses prefixcache's per-model radix-tree Decide; the final load-guarded pick
153159
// is done by clusterrouting.PickWithAffinity so the VRAM tier is preserved.
154-
func affinityPreferred(idx *prefixcache.Index, model string, chain []uint64, candidates []clusterrouting.ReplicaCandidate, cfg prefixcache.Config, now time.Time) string {
160+
func affinityPreferred(idx prefixcache.Provider, model string, chain []uint64, candidates []clusterrouting.ReplicaCandidate, cfg prefixcache.Config, now time.Time) string {
155161
if idx == nil || len(chain) == 0 || len(candidates) == 0 {
156162
return ""
157163
}
@@ -186,9 +192,9 @@ func (fs *FederatedServer) selectPeer(model string, body []byte, now time.Time)
186192
}
187193
var chain []uint64
188194
preferred := ""
189-
if fs.prefixIndex != nil && model != "" && len(body) > 0 {
195+
if fs.prefixProvider != nil && model != "" && len(body) > 0 {
190196
chain = prefixcache.ExtractChain(model, string(body), fs.prefixCfg)
191-
preferred = affinityPreferred(fs.prefixIndex, model, chain, candidates, fs.prefixCfg, now)
197+
preferred = affinityPreferred(fs.prefixProvider, model, chain, candidates, fs.prefixCfg, now)
192198
}
193199
best := clusterrouting.PickWithAffinity(candidates, preferred, fs.prefixCfg.BalanceAbsThreshold)
194200
if best == nil {
@@ -200,10 +206,29 @@ func (fs *FederatedServer) selectPeer(model string, body []byte, now time.Time)
200206
// observeServed records that peerID served the given chain for model, so the
201207
// next request sharing that prefix is routed back to the same warm peer.
202208
func (fs *FederatedServer) observeServed(model string, chain []uint64, peerID string, now time.Time) {
203-
if fs.prefixIndex == nil || len(chain) == 0 || peerID == "" || model == "" {
209+
if fs.prefixProvider == nil || len(chain) == 0 || peerID == "" || model == "" {
204210
return
205211
}
206-
fs.prefixIndex.Observe(model, chain, prefixcache.ReplicaKey{NodeID: peerID}, now)
212+
fs.prefixProvider.Observe(model, chain, prefixcache.ReplicaKey{NodeID: peerID}, now)
213+
}
214+
215+
// evictLoop periodically sweeps expired affinity entries so the in-memory tree
216+
// does not grow unbounded. Runs for the lifetime of the proxy.
217+
func (fs *FederatedServer) evictLoop(ctx context.Context) {
218+
interval := fs.prefixCfg.TTL / 2
219+
if interval <= 0 {
220+
interval = time.Minute
221+
}
222+
t := time.NewTicker(interval)
223+
defer t.Stop()
224+
for {
225+
select {
226+
case <-ctx.Done():
227+
return
228+
case now := <-t.C:
229+
fs.prefixProvider.Evict(now)
230+
}
231+
}
207232
}
208233

209234
func (fs *FederatedServer) RecordRequest(nodeID string) {

core/p2p/federated_server.go

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"time"
1414

1515
"github.com/mudler/LocalAI/core/schema"
16+
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
1617
"github.com/mudler/edgevpn/pkg/node"
1718
"github.com/mudler/xlog"
1819
)
@@ -62,10 +63,19 @@ func isWebsocketUpgrade(req *http.Request) bool {
6263
}
6364

6465
func (f *FederatedServer) Start(ctx context.Context) error {
65-
n, err := NewNode(f.p2ptoken)
66+
var extraOpts []node.Option
67+
if f.syncAffinity {
68+
extraOpts = append(extraOpts, node.EnableGenericHub, node.GenericChannelHandlers(f.affinityHandler()))
69+
}
70+
n, err := NewNode(f.p2ptoken, extraOpts...)
6671
if err != nil {
6772
return fmt.Errorf("creating a new node: %w", err)
6873
}
74+
if f.syncAffinity {
75+
f.prefixSync = prefixcache.NewSync(f.prefixIndex, &genericChannelPublisher{node: n})
76+
f.prefixProvider = f.prefixSync
77+
xlog.Info("Federation affinity sync enabled (generic channel)")
78+
}
6979
err = n.Start(ctx)
7080
if err != nil {
7181
return fmt.Errorf("creating a new node: %w", err)
@@ -77,6 +87,8 @@ func (f *FederatedServer) Start(ctx context.Context) error {
7787
return err
7888
}
7989

90+
go f.evictLoop(ctx)
91+
8092
return f.proxy(ctx, n)
8193
}
8294

core/p2p/federated_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,3 +179,18 @@ var _ = Describe("L7 request handling", func() {
179179
Expect(isWebsocketUpgrade(req)).To(BeFalse())
180180
})
181181
})
182+
183+
var _ = Describe("affinityPreferred with a sync provider", func() {
184+
ref := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
185+
186+
It("returns the warm peer when the provider is a Sync wrapping the index", func() {
187+
cfg := prefixcache.DefaultConfig()
188+
idx := prefixcache.NewIndex(cfg)
189+
sync := prefixcache.NewSync(idx, nil)
190+
chain := prefixcache.ExtractChain("m1", `{"model":"m1","messages":[{"role":"system","content":"a long shared system prompt for affinity"}]}`, cfg)
191+
sync.Observe("m1", chain, prefixcache.ReplicaKey{NodeID: "warm"}, ref)
192+
193+
cands := []clusterrouting.ReplicaCandidate{{NodeID: "warm"}, {NodeID: "cold"}}
194+
Expect(affinityPreferred(sync, "m1", chain, cands, cfg, ref)).To(Equal("warm"))
195+
})
196+
})

core/p2p/p2p.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -409,11 +409,12 @@ func ExposeService(ctx context.Context, host, port, token, servicesID string, mo
409409
return n, err
410410
}
411411

412-
func NewNode(token string) (*node.Node, error) {
412+
func NewNode(token string, extraOpts ...node.Option) (*node.Node, error) {
413413
nodeOpts, err := newNodeOpts(token)
414414
if err != nil {
415415
return nil, err
416416
}
417+
nodeOpts = append(nodeOpts, extraOpts...)
417418

418419
n, err := node.New(nodeOpts...)
419420
if err != nil {

0 commit comments

Comments
 (0)