Documentation
¶
Overview ¶
Package flags provides feature flags: on/off switches declared in Go, targeted at organisations and users, rolled out to a stable percentage of them, stored in PostgreSQL only when changed, and applied on every instance without a restart (ADR-0057).
reg := flags.NewRegistry()
newCheckout := flags.Bool(reg, "checkout.new_flow",
flags.Describe("The redesigned checkout."),
flags.Client(), // listed to signed-in clients by GET /v1/flags
)
store, err := flags.NewStore(ctx, pool, reg, recorder)
// Run store with the app's runners; ask the flag where it matters:
if newCheckout.Enabled(ctx) { ... }
Evaluation ¶
A flag's State is its declared default until an operator changes it with Store.Set. Flag.Enabled reads the state from memory and the actor from ctx (actor.From), and decides, first rule that applies:
- The flag is disabled: false.
- The actor acts in an organisation (actor.Actor.OrgID, set by orgs.RequireMember): false if the organisation is in Orgs.Deny, true if it is in Orgs.Allow.
- The actor is authenticated (any kind but anonymous, with an ID): false if its ID is in Users.Deny, true if it is in Users.Allow.
- A rollout percentage is set: true when the subject's Bucket is below it. The subject is the organisation when the actor acts in one, so every member gets the same answer, and the actor's ID otherwise. Anonymous callers have no subject: a percentage of 0 or 100 still applies to them, and any other percentage leaves them to the default.
- The state's default.
Evaluation is deterministic: the same flag, state and subject always give the same answer, on every instance, and raising a percentage only adds subjects. It never touches the database.
Stability: stable (ADR-0015, ADR-0054).
Index ¶
- Constants
- Variables
- func Bucket(key, subject string) int
- func Percent(p int) *int
- type Change
- type Evaluation
- type Flag
- type HistoryEntry
- type InvalidStateError
- type Option
- type Reason
- type Registry
- type State
- type Store
- func (s *Store) ClientFlags(ctx context.Context) []Evaluation
- func (s *Store) DeleteHistoryBefore(ctx context.Context, before time.Time, limit int) (int64, error)
- func (s *Store) Get(key string) (View, error)
- func (s *Store) History(ctx context.Context, key string, before int64, limit int) ([]HistoryEntry, error)
- func (s *Store) List() []View
- func (s *Store) OldestHistory(ctx context.Context) (oldest time.Time, ok bool, err error)
- func (s *Store) Reload(ctx context.Context) error
- func (s *Store) Reset(ctx context.Context, key string, change Change) (View, error)
- func (s *Store) Run(ctx context.Context) error
- func (s *Store) Set(ctx context.Context, key string, state State, change Change) (View, error)
- func (s *Store) UnknownKeys() []string
- type StoreOption
- type Targets
- type View
Examples ¶
Constants ¶
const ( // MaxTargets bounds each allow and deny list. Longer lists belong in a // rollout percentage, or in the app's own data. MaxTargets = 1000 // MaxIDLen bounds an organisation or user ID in a list. MaxIDLen = 100 )
Bounds of a State, checked by Store.Set and when states load.
const ( ActionChanged = "flags.flag.changed" ActionReset = "flags.flag.reset" )
Audit actions the store records. They are public API (ADR-0015).
const DefaultResyncInterval = 5 * time.Minute
DefaultResyncInterval is how often a running Store reloads every state, covering notifications lost while disconnected.
Variables ¶
var ( // ErrUnknownFlag reports a key that isn't declared in the registry. ErrUnknownFlag = errors.New("flags: unknown flag") // ErrVersionConflict reports that the flag changed after the caller // read it. Read it again and retry. ErrVersionConflict = errors.New("flags: flag changed since it was read") // ErrReasonRequired reports a change without a reason. Every flag change // needs one: flags change what users get in production. ErrReasonRequired = errors.New("flags: a reason is required to change a flag") // ErrActorRequired reports a change without an authenticated actor in // the context. ErrActorRequired = errors.New("flags: changes require an authenticated actor") // ErrInvalidState reports a state outside the bounds. The error is an // [*InvalidStateError]. ErrInvalidState = errors.New("flags: invalid state") )
Errors returned by Store methods. Check them with errors.Is.
var Migrations fs.FS = mustSub(migrationFiles, "migrations")
Migrations holds the module's goose migrations: the flags_states and flags_history tables. Apps copy them into db/migrations; tests can apply them directly with pgtest.
Functions ¶
func Bucket ¶
Bucket returns subject's rollout bucket for key, from 0 to 99: the first eight bytes of SHA-256 of key, a zero byte and subject, as a big-endian unsigned integer, modulo 100. A subject is in a rollout of p percent when its bucket is below p. Each flag buckets subjects independently, so the same 10% of users don't get every new feature first.
Example ¶
package main
import (
"fmt"
"gorbital.dev/modules/flags"
)
func main() {
// A subject is in a 30% rollout of a flag when its bucket is below 30.
fmt.Println(flags.Bucket("checkout.new_flow", "usr_1") < 30)
}
Output: true
Types ¶
type Change ¶
type Change struct {
// Version is the View.Version the caller last read.
Version int64
// Reason explains the change; always required.
Reason string
}
Change carries what a caller supplies with a change. The actor comes from the context.
type Evaluation ¶
Evaluation is a flag's answer for one actor.
type Flag ¶
type Flag struct {
// contains filtered or unexported fields
}
A Flag is a handle to one declared flag. It implements config.Value[bool], so library modules can accept a flag as a live boolean option without importing this package.
func Bool ¶
Bool declares an on/off flag. It is off for everyone until declared with DefaultOn or turned on with Store.Set. Invalid declarations are programming errors found at startup, so they panic.
Example ¶
package main
import (
"context"
"fmt"
"gorbital.dev/actor"
"gorbital.dev/modules/flags"
)
func main() {
reg := flags.NewRegistry()
newCheckout := flags.Bool(reg, "checkout.new_flow",
flags.Describe("The redesigned checkout."),
flags.Client(),
)
// Until an operator turns it on with Store.Set, a flag is off.
ctx := actor.With(context.Background(), actor.Actor{Kind: actor.KindUser, ID: "usr_1"})
fmt.Println(newCheckout.Evaluate(ctx))
}
Output: {checkout.new_flow false disabled}
func (*Flag) Enabled ¶
Enabled reports whether the flag is on for the actor in ctx, by the rules in the package documentation. It never touches the database.
func (*Flag) Evaluate ¶
func (f *Flag) Evaluate(ctx context.Context) Evaluation
Evaluate returns the flag's answer for the actor in ctx and the rule that decided it.
func (*Flag) Get ¶
Get returns Flag.Enabled, so a Flag is a config.Value[bool].
type HistoryEntry ¶
type HistoryEntry struct {
ID int64
Key string
OldState *State
NewState *State
// OldInvalid and NewInvalid report a recorded state that no longer
// passes validation, returned as nil.
OldInvalid bool
NewInvalid bool
Version int64
Reason string
ActorKind actor.Kind
ActorID string
RequestID string
ChangedAt time.Time
}
HistoryEntry is one change to a flag. A nil state means the declared default.
type InvalidStateError ¶
InvalidStateError describes why a state was rejected. Reason names the field and never contains an ID.
func (*InvalidStateError) Error ¶
func (e *InvalidStateError) Error() string
Error names the flag key and the reason the state was rejected.
func (*InvalidStateError) Unwrap ¶
func (e *InvalidStateError) Unwrap() error
Unwrap returns ErrInvalidState.
type Option ¶
type Option interface {
// contains filtered or unexported methods
}
An Option configures a flag declaration.
func Client ¶
func Client() Option
Client marks a flag clients may read: Store.ClientFlags evaluates only these, for endpoints such as GET /v1/flags. Other flags stay server-side, so their keys don't reveal unreleased features.
type Reason ¶
type Reason string
Reason is the rule that decided an evaluation.
const ( ReasonDisabled Reason = "disabled" ReasonOrgDenied Reason = "org_denied" ReasonOrgAllowed Reason = "org_allowed" ReasonUserDenied Reason = "user_denied" ReasonUserAllowed Reason = "user_allowed" ReasonRollout Reason = "rollout" ReasonDefault Reason = "default" )
Evaluation reasons, in the order the rules apply.
type Registry ¶
type Registry struct {
// contains filtered or unexported fields
}
A Registry holds declared flags and their current states. Declare every flag before calling NewStore. A Registry is safe for concurrent use.
type State ¶
type State struct {
// Enabled false turns the flag off for everyone, whatever the other
// fields say: a kill switch that keeps the targeting for later.
Enabled bool
// Default is the answer when no other rule applies.
Default bool
// Orgs and Users list IDs that always get false (Deny) or true (Allow).
// An ID can't be in both lists of the same kind.
Orgs Targets
Users Targets
// Percentage, from 0 to 100, turns the flag on for that share of
// subjects (see [Bucket]); nil means no rollout, so Default decides.
Percentage *int
}
State is how a flag decides, in the order of the rules in the package documentation.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store persists flag states in PostgreSQL and keeps a Registry current. It is an app.Runner: run it so changes from other instances arrive. It is safe for concurrent use.
func NewStore ¶
func NewStore(ctx context.Context, pool *pgxpool.Pool, reg *Registry, recorder audit.Recorder, opts ...StoreOption) (*Store, error)
NewStore loads every stored state into reg and returns the store. After NewStore, reg accepts no new declarations. Changes are recorded as "flags.flag.changed" and "flags.flag.reset" audit events through recorder.
The flags tables come from Migrations; apply them first.
Example ¶
package main
import (
"context"
"log"
"github.com/jackc/pgx/v5/pgxpool"
"gorbital.dev/audit"
"gorbital.dev/modules/flags"
)
func main() {
ctx := context.Background()
var (
pool *pgxpool.Pool // from postgres.Open
recorder audit.Recorder // for example an auditpg store
)
reg := flags.NewRegistry()
newCheckout := flags.Bool(reg, "checkout.new_flow", flags.Client())
store, err := flags.NewStore(ctx, pool, reg, recorder)
if err != nil {
log.Fatal(err)
}
// Run store alongside the HTTP server so changes from other instances
// arrive: app.Run(ctx, []app.Runner{server, store}, ...).
_ = store
_ = newCheckout
}
Output:
func (*Store) ClientFlags ¶
func (s *Store) ClientFlags(ctx context.Context) []Evaluation
ClientFlags evaluates every flag declared with Client for the actor in ctx, in declaration order, for endpoints that tell clients which features they get. It reads memory only.
func (*Store) DeleteHistoryBefore ¶
func (s *Store) DeleteHistoryBefore(ctx context.Context, before time.Time, limit int) (int64, error)
DeleteHistoryBefore deletes up to limit flag changes made before before, oldest first, and returns how many it deleted. Retention calls it until it deletes fewer than limit (ADR-0051).
func (*Store) Get ¶
Get returns one flag, or ErrUnknownFlag.
func (*Store) History ¶
func (s *Store) History(ctx context.Context, key string, before int64, limit int) ([]HistoryEntry, error)
History returns changes to key, newest first. For the next page, pass the last entry's ID as before (0 starts from the newest). limit is clamped to 1–100.
func (*Store) OldestHistory ¶
OldestHistory returns when the oldest recorded flag change was made; ok is false when there are none.
func (*Store) Reset ¶
Reset returns key to its declared state. It returns the same errors as Store.Set, except for invalid states.
func (*Store) Run ¶
Run keeps the registry current until ctx is done, then returns nil. It listens on a dedicated connection for changes committed by any instance, reloads everything after connecting (changes made while disconnected send no notification), and reloads everything every resync interval as a fallback. Connection failures are logged and retried with backoff.
func (*Store) Set ¶
Set validates state and stores it for key. The change applies to this instance immediately and to others within moments. It returns ErrUnknownFlag, an *InvalidStateError, ErrReasonRequired, ErrActorRequired or ErrVersionConflict. Setting the current state again changes nothing. Lists are stored sorted and without duplicates.
func (*Store) UnknownKeys ¶
UnknownKeys returns stored keys that no declaration matches, for example flags removed from the code. Their rows are kept and ignored.
type StoreOption ¶
type StoreOption interface {
// contains filtered or unexported methods
}
A StoreOption configures NewStore.
func WithLogger ¶
func WithLogger(logger *slog.Logger) StoreOption
WithLogger sets the logger for listener and invalid-state warnings. Default: discard.
func WithResyncInterval ¶
func WithResyncInterval(d time.Duration) StoreOption
WithResyncInterval sets how often Store.Run reloads every state. Default: DefaultResyncInterval.
type View ¶
type View struct {
Key string
Group string
Description string
// Client reports a flag declared with [Client].
Client bool
// State is the state in effect; Default is the declared state.
State State
Default State
// Modified reports whether a valid stored state replaces the declared
// one.
Modified bool
// InvalidStoredValue reports a stored state that fails validation, so
// the declared state is in effect.
InvalidStoredValue bool
// Version increases with every change; pass it back to change the flag.
// It is 0 for a flag that has never been changed.
Version int64
UpdatedAt time.Time
UpdatedBy string
}
View is a flag's declaration and current state, for operator APIs.