Files
Ilker ManapandClaude Opus 5 a075ff4120
CI / test (push) Successful in 1m19s
Add approximate prefix routing, and find out it did nothing
Tier 1 of plan §7.5: a radix tree of request prefixes that estimates which
replica has already prefilled one. Bytes, not tokens — needing the engine's
exact tokenizer is what makes tier 2 a separate project.

It went in inert, and the decision metric is what caught it: 144 lookups, zero
hits. The cause was a rule I had written and tested for. A node's label is the
first request that created it, so the second request for the same system
prompt diverges in its last few bytes — a new question after the same
preamble, the single most common shape there is. Requiring the whole label to
match threw away the 700 bytes they shared. What matters is the shared head,
because that is what the engine cached, and a partial label match now counts.
Two unit tests asserted the wrong rule and have been inverted.

With that fixed, measured end to end against engines whose caches are too
small for the working set: 22.5% cache hits without, 81.7% with, and the
breakdown shows the misses are exactly the first round, when the tree is
empty.

The scenario matters as much as the number. With a handful of prompts and a
large cache, scattering is free — every replica ends up holding everything —
and prefix routing can only add constraint. The first version of the test
measured that case and showed a loss, which was a true statement about a
situation the feature is not for.

Two things keep it from being the disaster prefix routing usually is. The
imbalance guard, which needs both an absolute and a relative gap before it
fires, because either alone misfires — absolute on an idle cluster, relative
on a busy one. And it is a scorer rather than a filter: affinity is a vote the
queue and inflight scorers can outvote, not a rule.

The tree records the replica the picker actually chose, not the one the scorer
proposed, and a node prefers the replica it has seen most rather than most
recently — a count is stable under a randomizing picker, where recency just
chases whatever the picker last did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 12:13:19 +02:00

166 lines
5.1 KiB
Go

package prefix
import (
"fmt"
"strings"
"sync"
"testing"
"time"
)
func tree(t *testing.T, cfg Config) *Tree {
t.Helper()
return New(cfg)
}
func TestTheLongestMatchWins(t *testing.T) {
// Two replicas have seen overlapping prefixes; the one that has seen more
// of this prompt is the one that has more of it cached.
tr := tree(t, Config{MinMatch: 4})
tr.Observe("You are a helpful assistant. ", "a")
tr.Observe("You are a helpful assistant. Answer in French. ", "b")
replica, n := tr.Match("You are a helpful assistant. Answer in French. What is 2+2?")
if replica != "b" {
t.Errorf("matched %q with %d bytes; the longer prefix belongs to b", replica, n)
}
if n < len("You are a helpful assistant. Answer in French. ") {
t.Errorf("matched only %d bytes", n)
}
}
func TestAShortOverlapIsNotAffinity(t *testing.T) {
// Two English prompts share "The". Treating coincidence as affinity is how
// prefix routing drowns one replica.
tr := tree(t, Config{MinMatch: 64})
tr.Observe("The quick brown fox", "a")
if replica, n := tr.Match("The slow green turtle"); replica != "" {
t.Errorf("a %d-byte coincidence was treated as a match to %q", n, replica)
}
}
func TestSplittingAnExistingBranch(t *testing.T) {
// The case a radix tree exists for: a new prompt diverges partway through
// a label that was one node.
tr := tree(t, Config{MinMatch: 4})
tr.Observe("abcdefgh", "a")
tr.Observe("abcdXYZW", "b")
if r, _ := tr.Match("abcdefgh and more"); r != "a" {
t.Errorf("the original branch matched %q", r)
}
if r, _ := tr.Match("abcdXYZW and more"); r != "b" {
t.Errorf("the new branch matched %q", r)
}
// The shared head belongs to both, because both were sent something
// beginning with it — and a prefix cache holds leading bytes, not whole
// prompts. An earlier version treated this as no match, which made the
// whole tree inert in practice.
if r, n := tr.Match("abcd"); r == "" || n != 4 {
t.Errorf("the shared head matched %q with %d bytes; both replicas hold it", r, n)
}
}
func TestAPartialLabelIsStillAMatch(t *testing.T) {
// The correction that made this feature work at all.
//
// The first request for a prompt becomes one long label. The second differs
// in its last few bytes — a new question after the same system prompt,
// which is the single most common shape there is. Requiring the whole label
// to match meant every lookup after the first returned nothing: measured as
// 144 misses and zero hits.
//
// What matters is the shared head, because that is what the engine cached.
tr := tree(t, Config{MinMatch: 2})
tr.Observe("abcdefghij", "a")
r, n := tr.Match("abcdeZZZZ")
if r != "a" {
t.Errorf("a partial match returned %q", r)
}
if n != 5 {
t.Errorf("matched %d bytes, want the 5 they share", n)
}
}
func TestEntriesExpire(t *testing.T) {
// An engine evicts on its own schedule, so an old record describes a cache
// that may be long gone. Believing it forever sends traffic towards
// something that is not there.
now := time.Now()
tr := New(Config{MinMatch: 4, TTL: time.Minute})
tr.now = func() time.Time { return now }
tr.Observe("shared prefix here", "a")
if r, _ := tr.Match("shared prefix here and more"); r != "a" {
t.Fatalf("fresh entry matched %q", r)
}
now = now.Add(2 * time.Minute)
if r, _ := tr.Match("shared prefix here and more"); r != "" {
t.Errorf("a stale entry still matched %q", r)
}
}
func TestForgettingAReplica(t *testing.T) {
// A replica that has gone away must stop attracting traffic. Nothing reuses
// a replica id, so the entries would otherwise only ever expire.
tr := tree(t, Config{MinMatch: 4})
tr.Observe("a long enough shared prefix", "a")
tr.Observe("a long enough shared prefix", "b")
tr.Forget("a")
r, _ := tr.Match("a long enough shared prefix and more")
if r == "a" {
t.Error("a forgotten replica still matches")
}
if r != "b" {
t.Errorf("forgetting one replica lost the other: %q", r)
}
}
func TestTheTreeIsBounded(t *testing.T) {
// A router that runs for months would otherwise accumulate every prompt it
// has ever seen.
tr := tree(t, Config{MinMatch: 4, MaxNodes: 200})
for i := range 2000 {
tr.Observe(fmt.Sprintf("prompt number %d with some trailing text", i), "a")
}
if got := tr.Size(); got > 400 {
t.Errorf("the tree grew to %d nodes against a limit of 200", got)
}
// And it still works afterwards.
tr.Observe("a fresh prompt after all that eviction", "b")
if r, _ := tr.Match("a fresh prompt after all that eviction, continued"); r != "b" {
t.Errorf("the tree stopped working after eviction: %q", r)
}
}
func TestConcurrentUse(t *testing.T) {
tr := tree(t, Config{MinMatch: 4, MaxNodes: 500})
var wg sync.WaitGroup
for i := range 32 {
wg.Add(1)
go func() {
defer wg.Done()
p := strings.Repeat("x", 40) + fmt.Sprint(i%4)
for range 50 {
tr.Observe(p, fmt.Sprintf("r%d", i%4))
tr.Match(p)
tr.Forget("r0")
}
}()
}
wg.Wait()
}
func TestEmptyInputsAreHarmless(t *testing.T) {
tr := tree(t, Config{})
tr.Observe("", "a")
tr.Observe("something", "")
if r, n := tr.Match(""); r != "" || n != 0 {
t.Errorf("an empty prompt matched %q/%d", r, n)
}
}