fluid-core/internal/runtime/resolver.go
tegwick 42891e08a2 Add revision registry and deterministic resolver
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
2026-09-04 02:06:19 +02:00

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