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) }