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
170 lines
5.6 KiB
Go
170 lines
5.6 KiB
Go
// Package runtime implements the FLUID deterministic data plane: the gateway,
|
|
// revision resolution, routing, backend connection and telemetry emission.
|
|
//
|
|
// Nothing here may depend on the evolution control plane to serve a request.
|
|
// ArchitectureBlueprint.md section 2 makes this an invariant: the interface
|
|
// runtime must continue to function when the Daimon, the model provider, the
|
|
// hypothesis store, the experiment controller and the AI budget are all
|
|
// unavailable.
|
|
package runtime
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sync"
|
|
|
|
"github.com/tegwick/fluid-core/internal/contract"
|
|
)
|
|
|
|
// Registry is the gateway's cached view of published control-plane state.
|
|
//
|
|
// It is deliberately a snapshot rather than a client. When the control plane
|
|
// dies the registry keeps answering from what it last held, which is what
|
|
// ArchitectureBlueprint.md section 34.6 requires: the runtime continues using
|
|
// cached published configuration, and no new promotions occur until
|
|
// control-plane consistency is restored.
|
|
type Registry struct {
|
|
mu sync.RWMutex
|
|
iface contract.InterfaceID
|
|
revisions map[contract.RevisionID]contract.Revision
|
|
policy contract.RoutingPolicy
|
|
hasPolicy bool
|
|
}
|
|
|
|
// NewRegistry returns an empty registry for one interface.
|
|
func NewRegistry(iface contract.InterfaceID) *Registry {
|
|
return &Registry{
|
|
iface: iface,
|
|
revisions: make(map[contract.RevisionID]contract.Revision),
|
|
}
|
|
}
|
|
|
|
var (
|
|
// ErrUnknownRevision is returned for a revision the registry has never seen.
|
|
ErrUnknownRevision = errors.New("unknown revision")
|
|
// ErrRevisionNotRoutable is returned for a revision that exists but must not
|
|
// receive traffic.
|
|
ErrRevisionNotRoutable = errors.New("revision not routable")
|
|
// ErrNoPolicy is returned before any routing policy has been loaded.
|
|
ErrNoPolicy = errors.New("no routing policy loaded")
|
|
// ErrWrongInterface guards against loading another interface's artifacts.
|
|
ErrWrongInterface = errors.New("artifact belongs to a different interface")
|
|
// ErrStalePolicy is returned when an older policy generation is offered.
|
|
ErrStalePolicy = errors.New("routing policy generation is not newer")
|
|
)
|
|
|
|
// PutRevision publishes a revision descriptor into the registry.
|
|
//
|
|
// The descriptor is expected to have been signature-verified already; this
|
|
// method enforces only the structural conditions the router depends on.
|
|
func (r *Registry) PutRevision(d contract.Revision) error {
|
|
if d.Interface != r.iface {
|
|
return fmt.Errorf("%w: descriptor is for %q, registry serves %q",
|
|
ErrWrongInterface, d.Interface, r.iface)
|
|
}
|
|
if d.Runtime.Upstream == "" {
|
|
return fmt.Errorf("revision %s: descriptor has no runtime upstream", d.ID)
|
|
}
|
|
if !d.State.Valid() {
|
|
return fmt.Errorf("revision %s: unknown state %q", d.ID, d.State)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.revisions[d.ID] = d
|
|
return nil
|
|
}
|
|
|
|
// Revision returns a published descriptor.
|
|
func (r *Registry) Revision(id contract.RevisionID) (contract.Revision, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
d, ok := r.revisions[id]
|
|
if !ok {
|
|
return contract.Revision{}, fmt.Errorf("%w: %s", ErrUnknownRevision, id)
|
|
}
|
|
return d, nil
|
|
}
|
|
|
|
// PutPolicy installs a routing policy.
|
|
//
|
|
// Generations are monotonic: an older policy is refused rather than applied.
|
|
// Without this a delayed delivery could silently roll traffic back to a
|
|
// superseded allocation, which would corrupt an in-flight experiment's
|
|
// measurement window.
|
|
func (r *Registry) PutPolicy(p contract.RoutingPolicy) error {
|
|
if p.Interface != r.iface {
|
|
return fmt.Errorf("%w: policy is for %q, registry serves %q",
|
|
ErrWrongInterface, p.Interface, r.iface)
|
|
}
|
|
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
|
|
if r.hasPolicy && p.Generation <= r.policy.Generation {
|
|
return fmt.Errorf("%w: offered %d, holding %d",
|
|
ErrStalePolicy, p.Generation, r.policy.Generation)
|
|
}
|
|
r.policy = p
|
|
r.hasPolicy = true
|
|
return nil
|
|
}
|
|
|
|
// Policy returns the current routing policy.
|
|
func (r *Registry) Policy() (contract.RoutingPolicy, error) {
|
|
r.mu.RLock()
|
|
defer r.mu.RUnlock()
|
|
if !r.hasPolicy {
|
|
return contract.RoutingPolicy{}, ErrNoPolicy
|
|
}
|
|
return r.policy, nil
|
|
}
|
|
|
|
// routableStates lists the descriptor states the router may send traffic to.
|
|
//
|
|
// ArchitectureBlueprint.md section 5.3 requires rejecting unpublished, failed
|
|
// and retired revisions. "created" is unpublished; "verified" has passed tests
|
|
// but has not been exposed; "retired" is finished.
|
|
var routableStates = map[contract.RevisionState]bool{
|
|
contract.RevisionStateExperiment: true,
|
|
contract.RevisionStateCandidate: true,
|
|
contract.RevisionStateStable: true,
|
|
contract.RevisionStateDeprecated: true,
|
|
}
|
|
|
|
// CheckRoutable reports whether a revision may currently receive traffic from
|
|
// the given cohort.
|
|
func (r *Registry) CheckRoutable(id contract.RevisionID, cohort contract.CohortID) error {
|
|
d, err := r.Revision(id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if !routableStates[d.State] {
|
|
return fmt.Errorf("%w: %s is %s", ErrRevisionNotRoutable, id, d.State)
|
|
}
|
|
if d.Policy.SecurityCheck != contract.RevisionPolicySecurityCheckPassed {
|
|
return fmt.Errorf("%w: %s has security_check=%s",
|
|
ErrRevisionNotRoutable, id, d.Policy.SecurityCheck)
|
|
}
|
|
if d.Policy.PolicyCheck != nil && *d.Policy.PolicyCheck == contract.RevisionPolicyPolicyCheckFailed {
|
|
return fmt.Errorf("%w: %s failed its policy check", ErrRevisionNotRoutable, id)
|
|
}
|
|
|
|
if d.Routing != nil && len(d.Routing.EligibleCohorts) > 0 {
|
|
if !containsCohort(d.Routing.EligibleCohorts, cohort) {
|
|
return fmt.Errorf("%w: cohort %q is not eligible for %s",
|
|
ErrRevisionNotRoutable, cohort, id)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func containsCohort(list []contract.CohortID, want contract.CohortID) bool {
|
|
for _, c := range list {
|
|
if c == want {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|