Skip to content

Commit 8e0c5e7

Browse files
fix(distributed): aggregate prefix cache pressure
Broadcast forced-disturb events and successful scale-up resets so every frontend shares the same rolling autoscale signal. Deduplicate NATS echoes, expose an origin-only Prometheus counter, and document cluster behavior. Closes #10083 Assisted-by: Codex:gpt-5
1 parent 704b87d commit 8e0c5e7

8 files changed

Lines changed: 186 additions & 2 deletions

File tree

core/application/distributed.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,7 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
282282
}
283283
idx := prefixcache.NewIndex(prefixCfg)
284284
prefixSync := prefixcache.NewSync(idx, natsClient)
285-
pressure = prefixcache.NewPressure(prefixCfg.PressureWindow)
285+
pressure = prefixcache.NewSyncedPressure(prefixCfg.PressureWindow, natsClient)
286286
prefixProvider = prefixSync
287287

288288
// Invalidate the prefix-cache index whenever a replica row is removed.
@@ -318,6 +318,11 @@ func initDistributed(cfg *config.ApplicationConfig, authDB *gorm.DB, configLoade
318318
}); err != nil {
319319
return nil, fmt.Errorf("subscribing to %s: %w", messaging.SubjectPrefixCacheInvalidate, err)
320320
}
321+
if _, err := messaging.SubscribeJSON(natsClient, messaging.SubjectPrefixCachePressure, func(ev messaging.PrefixCachePressureEvent) {
322+
pressure.ApplyPressure(ev, time.Now())
323+
}); err != nil {
324+
return nil, fmt.Errorf("subscribing to %s: %w", messaging.SubjectPrefixCachePressure, err)
325+
}
321326

322327
// Background eviction: sweep idle entries on the app context. Stopped
323328
// when the app context is cancelled (mirrors the reconciler loop which

core/services/messaging/subjects.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -478,6 +478,7 @@ const subjectSyncStatePrefix = "state."
478478
const (
479479
SubjectPrefixCacheObserve = "prefixcache.observe"
480480
SubjectPrefixCacheInvalidate = "prefixcache.invalidate"
481+
SubjectPrefixCachePressure = "prefixcache.pressure"
481482
)
482483

483484
// PrefixCacheObserveEvent announces that the replica (NodeID, Replica) served a
@@ -501,3 +502,12 @@ type PrefixCacheInvalidateEvent struct {
501502
NodeID string `json:"node_id"`
502503
Replica int `json:"replica"`
503504
}
505+
506+
// PrefixCachePressureEvent announces one forced-disturb observed by a frontend.
507+
// ID lets the publisher ignore its own NATS echo and all frontends ignore
508+
// redelivery without inflating the autoscale signal.
509+
type PrefixCachePressureEvent struct {
510+
ID string `json:"id"`
511+
Model string `json:"model"`
512+
Reset bool `json:"reset,omitempty"`
513+
}

core/services/messaging/subjects_prefixcache_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ var _ = Describe("PrefixCache subjects", func() {
1111
It("exposes stable subject constants", func() {
1212
Expect(messaging.SubjectPrefixCacheObserve).To(Equal("prefixcache.observe"))
1313
Expect(messaging.SubjectPrefixCacheInvalidate).To(Equal("prefixcache.invalidate"))
14+
Expect(messaging.SubjectPrefixCachePressure).To(Equal("prefixcache.pressure"))
1415
})
1516

1617
It("carries a replica index on the observe event", func() {
@@ -24,4 +25,10 @@ var _ = Describe("PrefixCache subjects", func() {
2425
one := messaging.PrefixCacheInvalidateEvent{Model: "m", NodeID: "A", Replica: 0}
2526
Expect(one.Replica).To(Equal(0))
2627
})
28+
29+
It("carries a unique pressure event identity", func() {
30+
ev := messaging.PrefixCachePressureEvent{ID: "frontend-a:1", Model: "m"}
31+
Expect(ev.ID).To(Equal("frontend-a:1"))
32+
Expect(ev.Model).To(Equal("m"))
33+
})
2734
})
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package prefixcache
2+
3+
import (
4+
"context"
5+
"sync"
6+
7+
"go.opentelemetry.io/otel"
8+
"go.opentelemetry.io/otel/attribute"
9+
"go.opentelemetry.io/otel/metric"
10+
)
11+
12+
var (
13+
pressureMetricsOnce sync.Once
14+
pressureCounter metric.Int64Counter
15+
)
16+
17+
func recordPressureMetric(model string) {
18+
pressureMetricsOnce.Do(func() {
19+
counter, err := otel.Meter("github.com/mudler/LocalAI").Int64Counter(
20+
"localai_prefix_cache_forced_disturb_total",
21+
metric.WithDescription("Prefix-cache forced-disturb events originated by this frontend"),
22+
)
23+
if err == nil {
24+
pressureCounter = counter
25+
}
26+
})
27+
if pressureCounter != nil {
28+
pressureCounter.Add(context.Background(), 1, metric.WithAttributes(attribute.String("model", model)))
29+
}
30+
}

core/services/nodes/prefixcache/pressure.go

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
11
package prefixcache
22

33
import (
4+
"crypto/rand"
5+
"encoding/hex"
6+
"fmt"
47
"sync"
8+
"sync/atomic"
59
"time"
10+
11+
"github.com/mudler/LocalAI/core/services/messaging"
12+
"github.com/mudler/xlog"
613
)
714

815
// Pressure is a concurrency-safe rolling per-model counter of forced-disturb
@@ -19,6 +26,10 @@ type Pressure struct {
1926
mu sync.Mutex
2027
window time.Duration
2128
events map[string][]time.Time
29+
seen map[string]time.Time
30+
pub publisher
31+
origin string
32+
seq atomic.Uint64
2233
}
2334

2435
// NewPressure creates a Pressure counter that remembers events for the given
@@ -27,7 +38,22 @@ func NewPressure(window time.Duration) *Pressure {
2738
return &Pressure{
2839
window: window,
2940
events: make(map[string][]time.Time),
41+
seen: make(map[string]time.Time),
42+
}
43+
}
44+
45+
// NewSyncedPressure creates a Pressure counter that broadcasts locally
46+
// originated events so every frontend sees the same cluster-wide signal.
47+
func NewSyncedPressure(window time.Duration, pub publisher) *Pressure {
48+
p := NewPressure(window)
49+
p.pub = pub
50+
var id [8]byte
51+
if _, err := rand.Read(id[:]); err != nil {
52+
p.origin = fmt.Sprintf("%d", time.Now().UnixNano())
53+
} else {
54+
p.origin = hex.EncodeToString(id[:])
3055
}
56+
return p
3157
}
3258

3359
// pruneLocked drops entries older than cutoff, compacting in place. The cutoff
@@ -47,13 +73,50 @@ func pruneLocked(ts []time.Time, cutoff time.Time) []time.Time {
4773
// older than the window, so the per-model slice stays bounded regardless of how
4874
// often Count runs.
4975
func (p *Pressure) Record(model string, now time.Time) {
76+
id := fmt.Sprintf("%s:%d", p.origin, p.seq.Add(1))
77+
p.record(model, id, now)
78+
recordPressureMetric(model)
79+
if p.pub != nil {
80+
ev := messaging.PrefixCachePressureEvent{ID: id, Model: model}
81+
if err := p.pub.Publish(messaging.SubjectPrefixCachePressure, ev); err != nil {
82+
xlog.Debug("prefixcache: pressure publish failed", "error", err)
83+
}
84+
}
85+
}
86+
87+
func (p *Pressure) record(model, id string, now time.Time) {
5088
p.mu.Lock()
5189
defer p.mu.Unlock()
5290
cutoff := now.Add(-p.window)
91+
for eventID, recordedAt := range p.seen {
92+
if recordedAt.Before(cutoff) {
93+
delete(p.seen, eventID)
94+
}
95+
}
96+
if id != "" {
97+
if _, exists := p.seen[id]; exists {
98+
return
99+
}
100+
p.seen[id] = now
101+
}
53102
kept := append(pruneLocked(p.events[model], cutoff), now)
54103
p.events[model] = kept
55104
}
56105

106+
// ApplyPressure records a pressure event received from NATS without
107+
// re-broadcasting it. Duplicate deliveries, including the publisher's own echo,
108+
// are ignored by event ID.
109+
func (p *Pressure) ApplyPressure(ev messaging.PrefixCachePressureEvent, now time.Time) {
110+
if ev.ID == "" || ev.Model == "" {
111+
return
112+
}
113+
if ev.Reset {
114+
p.reset(ev.Model, ev.ID, now)
115+
return
116+
}
117+
p.record(ev.Model, ev.ID, now)
118+
}
119+
57120
// Count returns the number of records for the model within [now-window, now],
58121
// dropping any entries older than the window so the backing slice stays bounded.
59122
func (p *Pressure) Count(model string, now time.Time) int {
@@ -76,7 +139,22 @@ func (p *Pressure) Count(model string, now time.Time) int {
76139
// (a pressure-triggered scale-up) so a single burst does not trigger repeated
77140
// scale-ups across consecutive ticks.
78141
func (p *Pressure) Reset(model string) {
142+
id := fmt.Sprintf("%s:%d", p.origin, p.seq.Add(1))
143+
p.reset(model, id, time.Now())
144+
if p.pub != nil {
145+
ev := messaging.PrefixCachePressureEvent{ID: id, Model: model, Reset: true}
146+
if err := p.pub.Publish(messaging.SubjectPrefixCachePressure, ev); err != nil {
147+
xlog.Debug("prefixcache: pressure reset publish failed", "error", err)
148+
}
149+
}
150+
}
151+
152+
func (p *Pressure) reset(model, id string, now time.Time) {
79153
p.mu.Lock()
80154
defer p.mu.Unlock()
155+
if _, exists := p.seen[id]; exists {
156+
return
157+
}
158+
p.seen[id] = now
81159
delete(p.events, model)
82160
}

core/services/nodes/prefixcache/pressure_test.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package prefixcache_test
33
import (
44
"time"
55

6+
"github.com/mudler/LocalAI/core/services/messaging"
67
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
78
. "github.com/onsi/ginkgo/v2"
89
. "github.com/onsi/gomega"
@@ -95,4 +96,54 @@ var _ = Describe("Pressure counter", func() {
9596
Expect(p.LenForTest("m")).To(Equal(1))
9697
Expect(p.Count("m", t0.Add(198*time.Minute))).To(Equal(1))
9798
})
99+
100+
It("broadcasts locally recorded pressure", func() {
101+
pub := &fakePub{}
102+
p := prefixcache.NewSyncedPressure(time.Minute, pub)
103+
104+
p.Record("m", t0)
105+
106+
Expect(p.Count("m", t0)).To(Equal(1))
107+
Expect(pub.published).To(HaveLen(1))
108+
ev := pub.published[0].(messaging.PrefixCachePressureEvent)
109+
Expect(ev.ID).ToNot(BeEmpty())
110+
Expect(ev.Model).To(Equal("m"))
111+
})
112+
113+
It("aggregates a peer pressure event and ignores redelivery", func() {
114+
p := prefixcache.NewSyncedPressure(time.Minute, &fakePub{})
115+
ev := messaging.PrefixCachePressureEvent{ID: "frontend-a:1", Model: "m"}
116+
117+
p.ApplyPressure(ev, t0)
118+
p.ApplyPressure(ev, t0.Add(time.Second))
119+
120+
Expect(p.Count("m", t0.Add(time.Second))).To(Equal(1))
121+
})
122+
123+
It("does not double-count its own broadcast echo", func() {
124+
pub := &fakePub{}
125+
p := prefixcache.NewSyncedPressure(time.Minute, pub)
126+
p.Record("m", t0)
127+
ev := pub.published[0].(messaging.PrefixCachePressureEvent)
128+
129+
p.ApplyPressure(ev, t0.Add(time.Second))
130+
131+
Expect(p.Count("m", t0.Add(time.Second))).To(Equal(1))
132+
})
133+
134+
It("broadcasts and applies a pressure reset after scaling", func() {
135+
pub := &fakePub{}
136+
origin := prefixcache.NewSyncedPressure(time.Minute, pub)
137+
peer := prefixcache.NewSyncedPressure(time.Minute, &fakePub{})
138+
origin.Record("m", t0)
139+
record := pub.published[0].(messaging.PrefixCachePressureEvent)
140+
peer.ApplyPressure(record, t0)
141+
142+
origin.Reset("m")
143+
reset := pub.published[1].(messaging.PrefixCachePressureEvent)
144+
peer.ApplyPressure(reset, t0.Add(time.Second))
145+
146+
Expect(reset.Reset).To(BeTrue())
147+
Expect(peer.Count("m", t0.Add(time.Second))).To(Equal(0))
148+
})
98149
})

docs/content/features/distributed-mode.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -946,7 +946,9 @@ Notes:
946946

947947
## Roadmap: Routing and Caching Enhancements
948948

949-
The scheduling algorithm above is load-based (least in-flight, then least-recently-used). Work is underway to make routing **prefix-cache-aware**: bias each request toward the replica that already holds the relevant KV/prefix cache (multi-turn conversations and shared system prompts), so backends reuse cache instead of recomputing it. The first step is a router-side radix tree of prompt-prefix hashes mapped to nodes, with longest-prefix match, a load guard that preserves round-robin behavior under imbalance, and NATS sync across frontends. It is purely a routing-layer hint (no backend changes) and never routes worse than today's round-robin.
949+
The scheduling algorithm supports **prefix-cache-aware** routing: bias each request toward the replica that already holds the relevant KV/prefix cache (multi-turn conversations and shared system prompts), so backends reuse cache instead of recomputing it. A router-side radix tree maps prompt-prefix hashes to nodes, with longest-prefix match, a load guard that preserves round-robin behavior under imbalance, and NATS sync across frontends. It is purely a routing-layer hint (no backend changes) and never routes worse than round-robin.
950+
951+
When the load guard must route away from a warm replica, the frontend emits a forced-disturb event. These events and the reset sent after a successful pressure-triggered scale-up are broadcast over NATS, so the rolling autoscale threshold is cluster-wide rather than per frontend. The Prometheus counter `localai_prefix_cache_forced_disturb_total{model="..."}` records events at their originating frontend; sum it across frontend replicas to inspect cluster pressure without counting the NATS copies.
950952

951953
Further enhancements, surfaced from a survey of SGLang, vLLM production-stack, Ray Serve, llm-d, AIBrix, and NVIDIA Dynamo, are tracked under the routing roadmap epic ([#10063](https://github.com/mudler/LocalAI/issues/10063)):
952954

pkg/natsauth/permissions_coverage_test.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@ var _ = Describe("Documented NATS service-user permissions", func() {
115115
frontendPublishes := []string{
116116
messaging.SubjectPrefixCacheObserve,
117117
messaging.SubjectPrefixCacheInvalidate,
118+
messaging.SubjectPrefixCachePressure,
118119
messaging.SubjectNodeBackendInstall("node-1"),
119120
messaging.SubjectGalleryProgress("op-1"),
120121
}

0 commit comments

Comments
 (0)