The registry is a cached snapshot rather than a control-plane client, so the data plane keeps serving when the control plane dies (Blueprint 34.6). Routing policy generations are monotonic: a delayed older policy is refused rather than silently rolling back an in-flight experiment's allocation. The resolver implements the Blueprint 5.2 precedence chain and records why each revision was chosen. Experiment allocation is a deterministic function of a sticky key namespaced by experiment id, so a consumer stays in one arm for the experiment's duration and does not land in the same arm of every concurrent experiment. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014KmVxhJ35tCo7rE7UnLwWu Assistant: claude-code Assistant-Model: opus Assistant-Process: 1116572@bnt-lap001 Assistant-Session: 8ba9bb93-a72a-4883-b189-2499cce5c400
275 lines
7.9 KiB
Go
275 lines
7.9 KiB
Go
package runtime
|
|
|
|
import (
|
|
"errors"
|
|
"testing"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
)
|
|
|
|
const testInterface contract.InterfaceID = "hall-publishing"
|
|
|
|
// descriptor builds a minimal routable revision descriptor.
|
|
func descriptor(id contract.RevisionID, state contract.RevisionState, cohorts ...contract.CohortID) contract.Revision {
|
|
d := contract.Revision{
|
|
SchemaVersion: "0.1",
|
|
ID: id,
|
|
Interface: testInterface,
|
|
State: state,
|
|
Contract: contract.RevisionContract{
|
|
Type: contract.RevisionContractTypeOpenapi,
|
|
Digest: contract.Digest("sha256:" + zeros(64)),
|
|
},
|
|
Runtime: contract.RevisionRuntime{Upstream: "http://adapter:8080"},
|
|
Intent: contract.RevisionIntent{Version: "IEI-1"},
|
|
Policy: contract.RevisionPolicy{
|
|
Compatibility: contract.RevisionPolicyCompatibilityAdditive,
|
|
SecurityCheck: contract.RevisionPolicySecurityCheckPassed,
|
|
},
|
|
}
|
|
if len(cohorts) > 0 {
|
|
d.Routing = &contract.RevisionRouting{EligibleCohorts: cohorts}
|
|
}
|
|
return d
|
|
}
|
|
|
|
func zeros(n int) string {
|
|
b := make([]byte, n)
|
|
for i := range b {
|
|
b[i] = '0'
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
func policyWith(defaultRev contract.RevisionID, gen int64, rules ...contract.RoutingPolicyRulesItem) contract.RoutingPolicy {
|
|
return contract.RoutingPolicy{
|
|
SchemaVersion: "0.1",
|
|
Interface: testInterface,
|
|
Generation: gen,
|
|
DefaultRevision: defaultRev,
|
|
Rules: rules,
|
|
}
|
|
}
|
|
|
|
func newFixture(t *testing.T, revs ...contract.Revision) *Registry {
|
|
t.Helper()
|
|
reg := NewRegistry(testInterface)
|
|
for _, r := range revs {
|
|
if err := reg.PutRevision(r); err != nil {
|
|
t.Fatalf("PutRevision(%s): %v", r.ID, err)
|
|
}
|
|
}
|
|
return reg
|
|
}
|
|
|
|
func TestResolvePrecedence(t *testing.T) {
|
|
reg := newFixture(t,
|
|
descriptor("R-1", contract.RevisionStateStable),
|
|
descriptor("R-2", contract.RevisionStateExperiment),
|
|
descriptor("R-3", contract.RevisionStateCandidate),
|
|
)
|
|
if err := reg.PutPolicy(policyWith("R-1", 1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res := NewResolver(reg, true)
|
|
|
|
t.Run("explicit wins", func(t *testing.T) {
|
|
got, err := res.Resolve(Request{ExplicitRevision: "R-3", BoundRevision: "R-2"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != "R-3" {
|
|
t.Errorf("got %s, want R-3", got.Revision)
|
|
}
|
|
if got.Reason != contract.FluidTelemetryResolutionReasonExplicitRevision {
|
|
t.Errorf("reason = %s", got.Reason)
|
|
}
|
|
})
|
|
|
|
t.Run("bound contract beats default", func(t *testing.T) {
|
|
got, err := res.Resolve(Request{BoundRevision: "R-2"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != "R-2" || got.Reason != contract.FluidTelemetryResolutionReasonBoundContract {
|
|
t.Errorf("got %s via %s, want R-2 via bound_contract", got.Revision, got.Reason)
|
|
}
|
|
})
|
|
|
|
t.Run("falls through to stable default", func(t *testing.T) {
|
|
got, err := res.Resolve(Request{})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != "R-1" || got.Reason != contract.FluidTelemetryResolutionReasonStableDefault {
|
|
t.Errorf("got %s via %s, want R-1 via stable_default", got.Revision, got.Reason)
|
|
}
|
|
})
|
|
|
|
t.Run("explicit disabled is ignored", func(t *testing.T) {
|
|
strict := NewResolver(reg, false)
|
|
got, err := strict.Resolve(Request{ExplicitRevision: "R-3"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision != "R-1" {
|
|
t.Errorf("got %s, want the default R-1 when pinning is disabled", got.Revision)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestUnroutableRevisionsAreRefused(t *testing.T) {
|
|
reg := newFixture(t,
|
|
descriptor("R-1", contract.RevisionStateStable),
|
|
descriptor("R-created", contract.RevisionStateCreated),
|
|
descriptor("R-retired", contract.RevisionStateRetired),
|
|
)
|
|
failed := descriptor("R-insecure", contract.RevisionStateStable)
|
|
failed.Policy.SecurityCheck = contract.RevisionPolicySecurityCheckFailed
|
|
if err := reg.PutRevision(failed); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.PutPolicy(policyWith("R-1", 1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := NewResolver(reg, true)
|
|
|
|
for _, id := range []contract.RevisionID{"R-created", "R-retired", "R-insecure"} {
|
|
if _, err := res.Resolve(Request{ExplicitRevision: id}); err == nil {
|
|
t.Errorf("resolving %s should have been refused", id)
|
|
} else if !errors.Is(err, ErrRevisionNotRoutable) {
|
|
t.Errorf("resolving %s: got %v, want ErrRevisionNotRoutable", id, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestBoundRevisionDegradesToDefault(t *testing.T) {
|
|
// A consumer pinned to a revision that has since retired should keep being
|
|
// served rather than start failing.
|
|
reg := newFixture(t,
|
|
descriptor("R-1", contract.RevisionStateStable),
|
|
descriptor("R-old", contract.RevisionStateRetired),
|
|
)
|
|
if err := reg.PutPolicy(policyWith("R-1", 1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
got, err := NewResolver(reg, false).Resolve(Request{BoundRevision: "R-old"})
|
|
if err != nil {
|
|
t.Fatalf("a retired binding should degrade, not fail: %v", err)
|
|
}
|
|
if got.Revision != "R-1" {
|
|
t.Errorf("got %s, want R-1", got.Revision)
|
|
}
|
|
}
|
|
|
|
func TestCohortEligibility(t *testing.T) {
|
|
reg := newFixture(t,
|
|
descriptor("R-1", contract.RevisionStateStable),
|
|
descriptor("R-2", contract.RevisionStateExperiment, "coding-agents"),
|
|
)
|
|
if err := reg.PutPolicy(policyWith("R-1", 1)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := reg.CheckRoutable("R-2", "coding-agents"); err != nil {
|
|
t.Errorf("eligible cohort refused: %v", err)
|
|
}
|
|
if err := reg.CheckRoutable("R-2", "partner-integrations"); err == nil {
|
|
t.Error("ineligible cohort accepted")
|
|
}
|
|
}
|
|
|
|
func TestAllocationIsStickyAndProportional(t *testing.T) {
|
|
reg := newFixture(t,
|
|
descriptor("R-1", contract.RevisionStateStable),
|
|
descriptor("R-2", contract.RevisionStateExperiment),
|
|
)
|
|
exp := contract.ExperimentID("E-1")
|
|
rule := contract.RoutingPolicyRulesItem{
|
|
Experiment: &exp,
|
|
Allocation: map[string]contract.UnitInterval{"R-1": 0.9, "R-2": 0.1},
|
|
}
|
|
if err := reg.PutPolicy(policyWith("R-1", 1, rule)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := NewResolver(reg, false)
|
|
|
|
// Stickiness: the same consumer must resolve identically every time.
|
|
first, err := res.Resolve(Request{ConsumerRef: "consumer-42"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 50; i++ {
|
|
again, err := res.Resolve(Request{ConsumerRef: "consumer-42"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if again.Revision != first.Revision {
|
|
t.Fatalf("assignment drifted: %s then %s", first.Revision, again.Revision)
|
|
}
|
|
}
|
|
|
|
// Proportionality: roughly a tenth of consumers should see the candidate.
|
|
const n = 4000
|
|
candidate := 0
|
|
for i := 0; i < n; i++ {
|
|
got, err := res.Resolve(Request{ConsumerRef: consumerName(i)})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got.Revision == "R-2" {
|
|
candidate++
|
|
}
|
|
if got.Experiment == nil || *got.Experiment != exp {
|
|
t.Fatalf("experiment not recorded on resolution for consumer %d", i)
|
|
}
|
|
}
|
|
share := float64(candidate) / n
|
|
if share < 0.07 || share > 0.13 {
|
|
t.Errorf("candidate share %.3f, want approximately 0.10", share)
|
|
}
|
|
}
|
|
|
|
func consumerName(i int) string {
|
|
digits := "0123456789"
|
|
out := []byte("consumer-")
|
|
if i == 0 {
|
|
return string(append(out, '0'))
|
|
}
|
|
var rev []byte
|
|
for i > 0 {
|
|
rev = append(rev, digits[i%10])
|
|
i /= 10
|
|
}
|
|
for j := len(rev) - 1; j >= 0; j-- {
|
|
out = append(out, rev[j])
|
|
}
|
|
return string(out)
|
|
}
|
|
|
|
func TestStalePolicyRefused(t *testing.T) {
|
|
reg := newFixture(t, descriptor("R-1", contract.RevisionStateStable))
|
|
if err := reg.PutPolicy(policyWith("R-1", 5)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := reg.PutPolicy(policyWith("R-1", 4)); !errors.Is(err, ErrStalePolicy) {
|
|
t.Errorf("older generation accepted: %v", err)
|
|
}
|
|
if err := reg.PutPolicy(policyWith("R-1", 5)); !errors.Is(err, ErrStalePolicy) {
|
|
t.Errorf("equal generation accepted: %v", err)
|
|
}
|
|
if err := reg.PutPolicy(policyWith("R-1", 6)); err != nil {
|
|
t.Errorf("newer generation refused: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestWrongInterfaceRefused(t *testing.T) {
|
|
reg := NewRegistry(testInterface)
|
|
other := descriptor("R-1", contract.RevisionStateStable)
|
|
other.Interface = "some-other-api"
|
|
if err := reg.PutRevision(other); !errors.Is(err, ErrWrongInterface) {
|
|
t.Errorf("foreign descriptor accepted: %v", err)
|
|
}
|
|
}
|