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
212 lines
7 KiB
Go
212 lines
7 KiB
Go
package runtime
|
|
|
|
import (
|
|
"fmt"
|
|
"hash/fnv"
|
|
"sort"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
)
|
|
|
|
// Request is the subset of an inbound request that revision resolution may
|
|
// consider. Nothing else is allowed to influence the decision: resolution must
|
|
// be a pure function of these fields and the loaded policy, or it stops being
|
|
// auditable (ArchitectureBlueprint.md section 5.2).
|
|
type Request struct {
|
|
// ExplicitRevision is a revision the consumer asked for by name.
|
|
ExplicitRevision contract.RevisionID
|
|
// BoundRevision comes from a client contract binding.
|
|
BoundRevision contract.RevisionID
|
|
// Cohort is the consumer's cohort assignment.
|
|
Cohort contract.CohortID
|
|
// Tenant identifies the calling tenant, where the interface is multi-tenant.
|
|
Tenant string
|
|
// ConsumerRef is the pseudonymous, stable consumer identity used to keep a
|
|
// long-lived consumer on one side of an experiment.
|
|
ConsumerRef string
|
|
// CorrelationID ties this request to its telemetry.
|
|
CorrelationID string
|
|
}
|
|
|
|
// Resolution is the outcome of revision resolution, including why.
|
|
//
|
|
// The reason is not decoration. Blueprint section 5.2 requires resolution to be
|
|
// auditable, and "which revision served this request" is unanswerable later
|
|
// without recording how it was chosen.
|
|
type Resolution struct {
|
|
Revision contract.RevisionID
|
|
Reason contract.FluidTelemetryResolutionReason
|
|
Experiment *contract.ExperimentID
|
|
// PolicyGeneration records which policy produced this decision.
|
|
PolicyGeneration int64
|
|
}
|
|
|
|
// Resolver implements the deterministic precedence chain.
|
|
type Resolver struct {
|
|
registry *Registry
|
|
// allowExplicit controls whether consumers may pin a revision by name. Some
|
|
// interfaces want this for migration testing; others must not expose it.
|
|
allowExplicit bool
|
|
}
|
|
|
|
// NewResolver returns a resolver over reg.
|
|
func NewResolver(reg *Registry, allowExplicit bool) *Resolver {
|
|
return &Resolver{registry: reg, allowExplicit: allowExplicit}
|
|
}
|
|
|
|
// Resolve selects the revision that will serve req.
|
|
//
|
|
// The order is fixed by ArchitectureBlueprint.md section 5.2:
|
|
//
|
|
// explicit revision -> bound client contract -> experiment assignment -> stable default
|
|
//
|
|
// Each step is skipped rather than failed when the candidate is not routable,
|
|
// so a retired pin or an ineligible cohort degrades to the default instead of
|
|
// erroring the request.
|
|
func (r *Resolver) Resolve(req Request) (Resolution, error) {
|
|
policy, err := r.registry.Policy()
|
|
if err != nil {
|
|
return Resolution{}, err
|
|
}
|
|
|
|
if r.allowExplicit && req.ExplicitRevision != "" {
|
|
if err := r.registry.CheckRoutable(req.ExplicitRevision, req.Cohort); err != nil {
|
|
// An explicit request for something unroutable is a consumer error
|
|
// worth surfacing, not something to silently reinterpret.
|
|
return Resolution{}, fmt.Errorf("explicit revision %s: %w", req.ExplicitRevision, err)
|
|
}
|
|
return Resolution{
|
|
Revision: req.ExplicitRevision,
|
|
Reason: contract.FluidTelemetryResolutionReasonExplicitRevision,
|
|
PolicyGeneration: policy.Generation,
|
|
}, nil
|
|
}
|
|
|
|
if req.BoundRevision != "" {
|
|
if err := r.registry.CheckRoutable(req.BoundRevision, req.Cohort); err == nil {
|
|
return Resolution{
|
|
Revision: req.BoundRevision,
|
|
Reason: contract.FluidTelemetryResolutionReasonBoundContract,
|
|
PolicyGeneration: policy.Generation,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
if rule, ok := matchRule(policy.Rules, req); ok {
|
|
chosen, ok := allocate(rule, req, policy.DefaultRevision)
|
|
if ok {
|
|
if err := r.registry.CheckRoutable(chosen, req.Cohort); err == nil {
|
|
reason := contract.FluidTelemetryResolutionReasonCohortRule
|
|
if rule.Experiment != nil {
|
|
reason = contract.FluidTelemetryResolutionReasonExperimentAssignment
|
|
}
|
|
return Resolution{
|
|
Revision: chosen,
|
|
Reason: reason,
|
|
Experiment: rule.Experiment,
|
|
PolicyGeneration: policy.Generation,
|
|
}, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := r.registry.CheckRoutable(policy.DefaultRevision, req.Cohort); err != nil {
|
|
return Resolution{}, fmt.Errorf("default revision %s: %w", policy.DefaultRevision, err)
|
|
}
|
|
return Resolution{
|
|
Revision: policy.DefaultRevision,
|
|
Reason: contract.FluidTelemetryResolutionReasonStableDefault,
|
|
PolicyGeneration: policy.Generation,
|
|
}, nil
|
|
}
|
|
|
|
// matchRule returns the first rule matching the request. Rules are evaluated in
|
|
// document order and the first match wins, so policy authors control precedence
|
|
// by ordering rather than by scoring.
|
|
func matchRule(rules []contract.RoutingPolicyRulesItem, req Request) (contract.RoutingPolicyRulesItem, bool) {
|
|
for _, rule := range rules {
|
|
if rule.Cohort != nil && *rule.Cohort != req.Cohort {
|
|
continue
|
|
}
|
|
if rule.Tenant != "" && rule.Tenant != req.Tenant {
|
|
continue
|
|
}
|
|
return rule, true
|
|
}
|
|
return contract.RoutingPolicyRulesItem{}, false
|
|
}
|
|
|
|
// allocate picks a revision from a rule's traffic shares.
|
|
//
|
|
// Assignment is a deterministic function of the sticky key, so a given consumer
|
|
// lands on the same side of an experiment for its whole duration. Random
|
|
// per-request assignment would make within-consumer comparisons meaningless and
|
|
// would let a client observe both revisions at once.
|
|
func allocate(rule contract.RoutingPolicyRulesItem, req Request, fallback contract.RevisionID) (contract.RevisionID, bool) {
|
|
if len(rule.Allocation) == 0 {
|
|
return "", false
|
|
}
|
|
|
|
// Sorting makes the traversal order independent of Go's map iteration, which
|
|
// is what turns a hash bucket into a stable assignment.
|
|
ids := make([]string, 0, len(rule.Allocation))
|
|
var total float64
|
|
for id, share := range rule.Allocation {
|
|
ids = append(ids, id)
|
|
total += float64(share)
|
|
}
|
|
sort.Strings(ids)
|
|
|
|
if total <= 0 {
|
|
return "", false
|
|
}
|
|
|
|
key := stickyKey(rule, req)
|
|
position := bucket(key) * total
|
|
|
|
var cumulative float64
|
|
for _, id := range ids {
|
|
cumulative += float64(rule.Allocation[id])
|
|
if position < cumulative {
|
|
return contract.RevisionID(id), true
|
|
}
|
|
}
|
|
// Floating-point drift at the top of the range.
|
|
return contract.RevisionID(ids[len(ids)-1]), true
|
|
}
|
|
|
|
// stickyKey chooses what keeps a consumer on one side of an experiment.
|
|
func stickyKey(rule contract.RoutingPolicyRulesItem, req Request) string {
|
|
mode := contract.RoutingPolicyRulesItemStickyByConsumerID
|
|
if rule.StickyBy != nil {
|
|
mode = *rule.StickyBy
|
|
}
|
|
|
|
var subject string
|
|
switch mode {
|
|
case contract.RoutingPolicyRulesItemStickyByTenant:
|
|
subject = req.Tenant
|
|
case contract.RoutingPolicyRulesItemStickyByCorrelationID:
|
|
subject = req.CorrelationID
|
|
case contract.RoutingPolicyRulesItemStickyByNone:
|
|
subject = req.CorrelationID
|
|
default:
|
|
subject = req.ConsumerRef
|
|
}
|
|
|
|
// Namespacing by experiment stops one consumer from landing in the same
|
|
// arm of every concurrent experiment, which would confound their results.
|
|
if rule.Experiment != nil {
|
|
return string(*rule.Experiment) + "\x00" + subject
|
|
}
|
|
return subject
|
|
}
|
|
|
|
// bucket maps a key into [0, 1).
|
|
func bucket(key string) float64 {
|
|
h := fnv.New64a()
|
|
_, _ = h.Write([]byte(key))
|
|
// 53 bits keeps the result exactly representable as a float64.
|
|
const mask = 1<<53 - 1
|
|
return float64(h.Sum64()&mask) / float64(mask+1)
|
|
}
|