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
This commit is contained in:
parent
76912adef8
commit
42891e08a2
5 changed files with 707 additions and 0 deletions
212
internal/runtime/resolver.go
Normal file
212
internal/runtime/resolver.go
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue