features

package module
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Sep 16, 2026 License: MIT Imports: 7 Imported by: 13

README

features

Feature gating and feature flags for Go tools, as values rather than process state.

features is the feature core of the phpboyscout Go toolkit: what a binary offers (declared at init into a Registry), what a tool has chosen (a Set, resolved once from an immutable Snapshot and the tool's states), and how a request asks (an Evaluator: static by default, dynamic over a Backend when a feature opts in).

go get gitlab.com/phpboyscout/go/features
// A package declares what it offers, at init.
features.MustDeclare(features.Default(), myFeature{})
features.Default().Contribute("reports", slotCommand, newReportsCommand)

// A tool resolves what it has, once, from a snapshot.
set, err := features.Resolve(features.Default().Snapshot(), features.Apply(nil,
    features.Enable("reports"),
))

// Everything static reads the set.
if set.Enabled("reports") { /* register the command */ }

// A service evaluates dynamic features through a backend, falling back to the set.
flags := features.Dynamic(set, backend, features.WithInitTimeout(2*time.Second))
dec, err := flags.Evaluate(ctx, "beta-ranking", features.EvalContext{TargetingKey: user})

Nothing seals and nothing after main panics: a reader takes a snapshot, and a later registration is simply not in it. The static/dynamic line is enforced by the descriptor, not by convention: a feature that does not report IsDynamic never reaches a backend.

No vendor SDK is here. A backend is an adapter module implementing Backend; depfootprint_test.go fails the build if a vendor, a framework or a CLI library reaches the graph.

Documentation: features.go.phpboyscout.uk. API: pkg.go.dev.

Licence

MIT. See LICENSE.

Documentation

Overview

Package features is the feature core of the phpboyscout Go toolkit: what a binary offers (declared at init into a Registry), what a tool has chosen (a Set, resolved once from an immutable Snapshot and the tool's states), and how a request asks (an Evaluator, static by default and dynamic over a Backend when a feature opts in).

Declaration is process-wide because a blank import can only reach package state; everything after main works with values. A reader takes a Snapshot and is never surprised by a later registration, so nothing seals and nothing after main panics.

The static/dynamic rule: anything that shapes a command tree, help, completion or a generated artefact is static and reads Set.Enabled, fixed at construction. A Descriptor that reports IsDynamic may be overridden at evaluation time by a Backend through Dynamic, which answers the static state as the fallback for any error, a not-ready backend, or a flag the backend does not know or has disabled. No vendor SDK is here; a backend is an adapter module implementing Backend.

Extracted from go-tool-base (spec 0199), which builds its command registration, doctor inventory and code generator on it.

Index

Constants

This section is empty.

Variables

View Source
var (
	// ErrInvalidDescriptor reports a descriptor with no ID or no kind.
	ErrInvalidDescriptor = errors.NewSentinel("features.invalid_descriptor", "features: descriptor is incomplete")
	// ErrDuplicateFeature reports a second declaration for one ID.
	ErrDuplicateFeature = errors.NewSentinel("features.duplicate_feature", "features: feature is already declared")
	// ErrUnknownFeature reports an ID the snapshot does not hold.
	ErrUnknownFeature = errors.NewSentinel("features.unknown_feature", "features: unknown feature")
	// ErrContributionType reports a contribution that is not the type the
	// reader asked for.
	ErrContributionType = errors.NewSentinel("features.contribution_type", "features: contribution has an unexpected type")
)
View Source
var ErrBackendNotReady = errors.NewSentinel("features.backend_not_ready", "features: flag backend is not ready")

ErrBackendNotReady reports a Backend that has not finished Init (or has gone stale); the Set answered instead.

Functions

func ContributionsOf

func ContributionsOf[T any](s Set, id ID, slot Slot) ([]T, error)

ContributionsOf returns the contributions under id and slot that are a T, and an error naming the feature and slot when any is not. The well-typed values are returned either way so a reader can carry on.

func MustDeclare

func MustDeclare(r Registry, d Descriptor)

MustDeclare declares d on r and panics on a rejected descriptor. It is for init, where there is no caller to hand an error to and a silently dropped feature would surface much later as a missing entry somewhere else.

Types

type Backend

type Backend interface {
	Resolve(ctx context.Context, id ID, fallback bool, ec EvalContext) (Decision, error)
	Init(ctx context.Context) error
	Ready() bool
	Watch(ctx context.Context) <-chan Change
	Close() error
}

Backend resolves a dynamic feature given the caller's fallback: the value it hands back for a flag it does not know or has disabled, which is OpenFeature's defaultValue and this package's Set state (go-tool-base spec 0199 D10). It carries the lifecycle a vendor SDK needs: Init before the first Resolve, Ready while answers are trustworthy, Watch for pushed updates (nil when it does not push), Close on shutdown.

type Change

type Change struct {
	ID ID
}

Change is a Backend's notice that a flag's value may have moved. An empty ID means everything may have changed.

type Decision

type Decision struct {
	Enabled bool
	Reason  Reason
	// Variant is empty for a plain toggle; a multivariate backend fills it.
	Variant string
}

Decision is an Evaluator's answer.

type Descriptor

type Descriptor interface {
	FeatureID() ID
	FeatureKind() Kind
	// DefaultOn reports whether the feature is enabled when the tool expresses
	// no preference.
	DefaultOn() bool
	// IsDynamic reports whether a Backend may override the static state at
	// evaluation time (go-tool-base spec 0199 D7). A false answer keeps the feature off
	// every backend's path.
	IsDynamic() bool
}

Descriptor is what the core needs to know about a feature. It is an interface so a consumer carries its own facts alongside (go-tool-base adds the identifiers its code generator emits).

type DynamicEvaluator

type DynamicEvaluator struct {
	// contains filtered or unexported fields
}

DynamicEvaluator is the production Evaluator: a static-only feature is answered from the Set without consulting the backend; a dynamic one is resolved by the backend with the Set state as its fallback, and on any error, a not-ready backend, or an unknown flag the Set answers with ReasonFallback and the error alongside (go-tool-base spec 0199 D10). It is exported so a consumer can embed it.

func Dynamic

func Dynamic(set Set, backend Backend, opts ...DynamicOption) *DynamicEvaluator

Dynamic builds the production Evaluator over set and backend. Which features are dynamic is fixed here from the Set's descriptors, so an evaluation is a map lookup before it decides whether to ask the backend.

func (*DynamicEvaluator) Close

func (e *DynamicEvaluator) Close() error

Close closes the backend.

func (*DynamicEvaluator) Evaluate

func (e *DynamicEvaluator) Evaluate(ctx context.Context, id ID, ec EvalContext) (Decision, error)

Evaluate implements Evaluator.

func (*DynamicEvaluator) Init

func (e *DynamicEvaluator) Init(ctx context.Context) error

Init initialises the backend within the configured timeout. An error leaves the evaluator usable: it answers with fallbacks until the backend is Ready.

func (*DynamicEvaluator) Ready

func (e *DynamicEvaluator) Ready() bool

Ready reports the backend's readiness.

func (*DynamicEvaluator) Watch

func (e *DynamicEvaluator) Watch(ctx context.Context) <-chan Change

Watch is the backend's change channel; nil when it does not push.

type DynamicOption

type DynamicOption func(*DynamicEvaluator)

DynamicOption configures Dynamic.

func WithInitTimeout

func WithInitTimeout(d time.Duration) DynamicOption

WithInitTimeout bounds Init: a short-lived process gives its backend a short budget and evaluates with fallbacks if it is not met. Zero means no bound.

func WithOnFallback

func WithOnFallback(fn func(id ID, err error)) DynamicOption

WithOnFallback is called each time an evaluation falls back to the Set because the backend errored, was not ready, or did not know the flag; a service logs it.

type EvalContext

type EvalContext struct {
	TargetingKey string
	Attributes   map[string]any
}

EvalContext is who is asking: a targeting key and attributes, the shape every flag system and the OpenFeature specification use. Empty for a static answer.

type Evaluator

type Evaluator interface {
	Evaluate(ctx context.Context, id ID, ec EvalContext) (Decision, error)
}

Evaluator answers whether a feature is on for this request. A Set is one; a Dynamic evaluator over a Backend is the other (go-tool-base spec 0199 D10).

type ID

type ID string

ID is a feature's identity: its config and manifest name.

const Global ID = ""

Global is the contribution target that applies to every feature, used for middleware that wraps every command.

type Kind

type Kind string

Kind classifies a feature so "every forge" is a query rather than a list.

type MemoryRegistry

type MemoryRegistry struct {
	// contains filtered or unexported fields
}

MemoryRegistry is the default Registry: append-only, mutex-guarded, never sealed. It is exported so a consumer can embed it.

func NewRegistry

func NewRegistry() *MemoryRegistry

NewRegistry returns an empty MemoryRegistry.

func (*MemoryRegistry) Contribute

func (r *MemoryRegistry) Contribute(id ID, slot Slot, v any)

Contribute records v under id and slot. A contribution for an ID nobody has declared is kept: declaration and contribution may come from different packages in either init order, and a Set only hands out the contributions of enabled (hence declared) features.

func (*MemoryRegistry) Declare

func (r *MemoryRegistry) Declare(d Descriptor) error

Declare records d, refusing an incomplete descriptor or a second one for the same ID.

func (*MemoryRegistry) Snapshot

func (r *MemoryRegistry) Snapshot() Snapshot

Snapshot returns an immutable copy of everything declared and contributed so far, in the total order Snapshot documents.

type Mutator

type Mutator func([]State) []State

Mutator edits a list of states; Enable and Disable are the two.

func Disable

func Disable(id ID) Mutator

Disable returns a Mutator that sets id off, replacing an earlier state for it.

func Enable

func Enable(id ID) Mutator

Enable returns a Mutator that sets id on, replacing an earlier state for it.

type Ranked

type Ranked interface {
	Rank() (rank int, ok bool)
}

Ranked is an optional Descriptor capability: a descriptor that reports a rank (ok true) sorts before every unranked one, lowest first. It is how a consumer keeps a declared order for its own features while everything else sorts by kind and ID.

type Reason

type Reason string

Reason says how a Decision was reached.

const (
	// ReasonStatic is a static-only feature, or a Set, answering from the
	// resolved state.
	ReasonStatic Reason = "static"
	// ReasonDefault is a backend answering with its default rule.
	ReasonDefault Reason = "default"
	// ReasonTargeting is a backend matching the EvalContext.
	ReasonTargeting Reason = "targeting"
	// ReasonDisabled is a backend that has the flag disabled: the fallback
	// (the Set state) applies, and that is a clean answer.
	ReasonDisabled Reason = "disabled"
	// ReasonFallback is a backend error or a backend not ready: the Set
	// answered, and the error travels alongside.
	ReasonFallback Reason = "fallback"
	// ReasonError is no answer possible (an ID nobody declared).
	ReasonError Reason = "error"
)

type Registry

type Registry interface {
	Declare(d Descriptor) error
	Contribute(id ID, slot Slot, v any)
	Snapshot() Snapshot
}

Registry is where a package declares its features and contributions, and where a root takes its Snapshot. Default is the init-time instance; a test or a host builds its own with NewRegistry.

func Default

func Default() Registry

Default is the process-wide registry that init-time declarations target. It is read-only by convention once main begins: a root snapshots it, and a test that needs its own features builds its own Registry.

type Resolver

type Resolver interface {
	Resolve(s Snapshot, states []State) (Set, error)
}

Resolver turns a Snapshot and a tool's states into a Set. The default applies every descriptor's DefaultOn, then the states in order; a consumer with another precedence supplies its own.

type Set

type Set interface {
	Evaluator
	Enabled(id ID) bool
	Descriptors() []Descriptor
	EnabledDescriptors() []Descriptor
	// Contributions returns the values registered under id and slot when id
	// is enabled, and nothing otherwise.
	Contributions(id ID, slot Slot) []any
	// Ignored lists the disable states that named an ID the snapshot lacks
	// (go-tool-base spec 0199 OQ2): you can switch off what you did not link,
	// and a diagnostic can report that you did.
	Ignored() []State
}

Set is what a tool has: the resolved enabled state of every declared feature, the contributions of the enabled ones, and a static Evaluator.

func Resolve

func Resolve(s Snapshot, states []State) (Set, error)

Resolve is DefaultResolver().Resolve.

type Slot

type Slot string

Slot names a contribution channel. The core knows a slot is a name and a value; the consumer defines the slots and asserts the types.

type Snapshot

type Snapshot interface {
	Descriptors() []Descriptor
	OfKind(k Kind) []Descriptor
	Lookup(id ID) (Descriptor, bool)
	Contributions(id ID, slot Slot) []any
	// Contributed lists every ID that has at least one contribution, sorted,
	// Global included when it has any. It is how a reader enumerates without
	// assuming every contributor was also declared.
	Contributed() []ID
}

Snapshot is an immutable view of a Registry at one moment. Descriptors are in a total order derived from data, never from init sequencing: Ranked descriptors first by rank, then the rest by kind and ID. Go orders init by dependency then filename, which is stable for one build but moves with the import graph, and reports and golden files depend on this order.

type State

type State struct {
	ID      ID
	Enabled bool
}

State is a tool's decision about one feature.

func Apply

func Apply(states []State, mutators ...Mutator) []State

Apply runs the mutators over states in order.

type StateResolver

type StateResolver struct{}

StateResolver is the default Resolver.

func DefaultResolver

func DefaultResolver() *StateResolver

DefaultResolver returns the default Resolver.

func (StateResolver) Resolve

func (StateResolver) Resolve(s Snapshot, states []State) (Set, error)

Resolve applies defaults then states. Enabling an unknown ID is an error; disabling one is ignored and listed.

type StaticBackend

type StaticBackend struct {
	// contains filtered or unexported fields
}

StaticBackend is a Backend over a Set: every answer is the static one. It is what lets Dynamic be tested and wired with no vendor at all.

func SetBackend

func SetBackend(set Set) *StaticBackend

SetBackend wraps set as a Backend.

func (*StaticBackend) Close

func (b *StaticBackend) Close() error

Close is a no-op.

func (*StaticBackend) Init

Init is a no-op.

func (*StaticBackend) Ready

func (b *StaticBackend) Ready() bool

Ready is always true.

func (*StaticBackend) Resolve

func (b *StaticBackend) Resolve(ctx context.Context, id ID, _ bool, ec EvalContext) (Decision, error)

Resolve answers from the Set.

func (*StaticBackend) Watch

func (b *StaticBackend) Watch(context.Context) <-chan Change

Watch returns nil: a Set never changes.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL