Files
wehub-resource-sync 1b8708893a
Security Scan / tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:12:26 +08:00

170 lines
6.9 KiB
Go

package prefixcache_test
import (
"sync"
"time"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/mudler/LocalAI/core/services/nodes/prefixcache"
)
var t0 = time.Date(2026, 5, 29, 12, 0, 0, 0, time.UTC)
var _ = Describe("Index provider", func() {
cfg := prefixcache.DefaultConfig()
It("returns no hot match before anything is observed", func() {
idx := prefixcache.NewIndex(cfg)
d := idx.Decide("m", []uint64{1, 2, 3}, []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}, t0)
Expect(d.HasHot).To(BeFalse())
// cold order present (all weights zero -> deterministic by node id)
Expect(d.ColdOrder).To(ConsistOf(rk("A", 0), rk("B", 0)))
})
It("returns the observed replica as hot match with the right ratio", func() {
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0)
d := idx.Decide("m", []uint64{1, 2, 3, 4, 5}, []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}, t0)
Expect(d.HasHot).To(BeTrue())
Expect(d.Hot).To(Equal(rk("A", 0)))
Expect(d.MatchRatio).To(BeNumerically("~", 4.0/5.0, 0.001))
})
It("orders cold candidates by ascending cacheWeight", func() {
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1}, rk("A", 0), t0)
idx.Observe("m", []uint64{2}, rk("A", 0), t0) // A weight 2
idx.Observe("m", []uint64{3}, rk("B", 0), t0) // B weight 1
d := idx.Decide("m", []uint64{9}, []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}, t0)
Expect(d.HasHot).To(BeFalse())
Expect(d.ColdOrder).To(Equal([]prefixcache.ReplicaKey{rk("B", 0), rk("A", 0)})) // B lower weight first
})
It("drops the hot match when the matched replica is not in the candidate set", func() {
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0)
// A holds the longest match, but A is not a candidate (offline /
// unloaded). The matched replica must be ignored so cold placement runs
// and no false forced-disturb fires upstream.
d := idx.Decide("m", []uint64{1, 2, 3, 4, 5}, []prefixcache.ReplicaKey{rk("B", 0), rk("C", 0)}, t0)
Expect(d.HasHot).To(BeFalse())
Expect(d.MatchRatio).To(Equal(0.0))
Expect(d.ColdOrder).To(ConsistOf(rk("B", 0), rk("C", 0)))
})
It("returns a hot match for a query that only shares a prefix with an observed chain", func() {
// The real-world case: a replica served chain [1,2,3,4]; a new request
// shares the leading block [1,2,3] but diverges at the tail ([1,2,3,9]).
// With prefix matching (value recorded at every node) Decide must still
// route to the warm replica, matching at the depth of the shared prefix.
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0)
d := idx.Decide("m", []uint64{1, 2, 3, 9}, []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}, t0)
Expect(d.HasHot).To(BeTrue())
Expect(d.Hot).To(Equal(rk("A", 0)))
Expect(d.MatchRatio).To(BeNumerically("~", 3.0/4.0, 0.001)) // shared [1,2,3] of len-4 query
})
It("keeps the hot match when the matched replica is a candidate", func() {
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0)
d := idx.Decide("m", []uint64{1, 2, 3, 4, 5}, []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0)}, t0)
Expect(d.HasHot).To(BeTrue())
Expect(d.Hot).To(Equal(rk("A", 0)))
Expect(d.MatchRatio).To(BeNumerically("~", 4.0/5.0, 0.001))
})
It("tracks affinity per replica, not per node", func() {
// Two replicas on the SAME node, each serving a different chain that share
// a leading block. The hot match for a query extending chain1 must be the
// EXACT replica that served chain1, not the other replica on the same node.
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0) // replica 0 owns [1,2,3,4]
idx.Observe("m", []uint64{1, 2, 5, 6}, rk("A", 1), t0) // replica 1 owns [1,2,5,6]
cands := []prefixcache.ReplicaKey{rk("A", 0), rk("A", 1)}
d := idx.Decide("m", []uint64{1, 2, 3, 4, 7}, cands, t0)
Expect(d.HasHot).To(BeTrue())
Expect(d.Hot).To(Equal(rk("A", 0))) // distinct replicas on one node have distinct affinity
d2 := idx.Decide("m", []uint64{1, 2, 5, 6, 7}, cands, t0)
Expect(d2.HasHot).To(BeTrue())
Expect(d2.Hot).To(Equal(rk("A", 1)))
})
It("Invalidate drops one replica while InvalidateNode drops all replicas of a node", func() {
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0)
idx.Observe("m", []uint64{5, 6, 7, 8}, rk("A", 1), t0)
cands := []prefixcache.ReplicaKey{rk("A", 0), rk("A", 1)}
// Invalidate replica 0 only: replica 1 survives.
idx.Invalidate("m", rk("A", 0))
Expect(idx.Decide("m", []uint64{1, 2, 3, 4}, cands, t0).HasHot).To(BeFalse())
d1 := idx.Decide("m", []uint64{5, 6, 7, 8}, cands, t0)
Expect(d1.HasHot).To(BeTrue())
Expect(d1.Hot).To(Equal(rk("A", 1)))
// Re-observe both, then InvalidateNode drops BOTH replicas.
idx.Observe("m", []uint64{1, 2, 3, 4}, rk("A", 0), t0)
idx.InvalidateNode("m", "A")
Expect(idx.Decide("m", []uint64{1, 2, 3, 4}, cands, t0).HasHot).To(BeFalse())
Expect(idx.Decide("m", []uint64{5, 6, 7, 8}, cands, t0).HasHot).To(BeFalse())
})
It("forgets a replica on Invalidate", func() {
idx := prefixcache.NewIndex(cfg)
idx.Observe("m", []uint64{1, 2}, rk("A", 0), t0)
idx.Invalidate("m", rk("A", 0))
d := idx.Decide("m", []uint64{1, 2}, []prefixcache.ReplicaKey{rk("A", 0)}, t0)
Expect(d.HasHot).To(BeFalse())
})
It("does not intern an empty tree when invalidating a model that has none", func() {
idx := prefixcache.NewIndex(cfg)
Expect(idx.TreeCountForTest()).To(Equal(0))
// Round-robin model that never used the prefix cache: invalidating a
// replica removal must be a no-op and must not retain a tree.
idx.Invalidate("never-cached", rk("A", 0))
idx.Invalidate("never-cached", rk("B", 0))
idx.InvalidateNode("other", "C")
Expect(idx.TreeCountForTest()).To(Equal(0))
// And a Decide afterwards still works without a hot match.
d := idx.Decide("never-cached", []uint64{1}, []prefixcache.ReplicaKey{rk("A", 0)}, t0)
Expect(d.HasHot).To(BeFalse())
})
It("is safe for concurrent Decide/Observe/Invalidate (run with -race)", func() {
idx := prefixcache.NewIndex(cfg)
models := []string{"m1", "m2"}
nodes := []string{"A", "B", "C"}
cands := []prefixcache.ReplicaKey{rk("A", 0), rk("B", 0), rk("C", 0)}
var wg sync.WaitGroup
for g := range 8 {
wg.Add(1)
go func(g int) {
defer GinkgoRecover()
defer wg.Done()
model := models[g%len(models)]
node := nodes[g%len(nodes)]
now := t0
for i := range 200 {
chain := []uint64{uint64(g), uint64(i % 7), uint64(i)}
switch i % 4 {
case 0:
idx.Observe(model, chain, prefixcache.ReplicaKey{NodeID: node, Replica: i % 2}, now)
case 1:
idx.Decide(model, chain, cands, now)
case 2:
idx.Invalidate(model, prefixcache.ReplicaKey{NodeID: node, Replica: i % 2})
case 3:
idx.InvalidateNode(model, node)
}
now = now.Add(time.Millisecond)
}
}(g)
}
wg.Wait()
})
})