Skip to content

About

Feature flags and gradual rollouts for Go: targeting rules, sticky percentage rollouts, variants, and in-memory evaluation with Postgres push updates.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Latest commit

 

History

2 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

go-featureflags

tests Go Reference

Feature toggles and gradual rollouts for Go SaaS applications. Flags are evaluated against an immutable in-memory snapshot, so a request never waits on the database: kill switches, user and tenant targeting, attribute rules, sticky percentage rollouts and weighted experiments resolve in roughly 100 ns without allocating. Snapshots refresh by polling and, with PostgreSQL, by push through LISTEN/NOTIFY. Writes go through a validating management API with optimistic concurrency and audit hooks.

Install

go get github.com/byteminus/go-featureflags

Requires Go 1.26 or later.

Quick start

package main

import (
	"context"
	"log"
	"os"

	"github.com/jackc/pgx/v5"
	"github.com/jackc/pgx/v5/pgxpool"

	featureflags "github.com/byteminus/go-featureflags"
	"github.com/byteminus/go-featureflags/pgxstore"
)

func main() {
	ctx := context.Background()
	url := os.Getenv("DATABASE_URL")
	pool, err := pgxpool.New(ctx, url)
	if err != nil {
		log.Fatal(err)
	}
	defer pool.Close()

	store, err := pgxstore.New(pool)
	if err != nil {
		log.Fatal(err)
	}
	if err := store.CreateSchema(ctx); err != nil {
		log.Fatal(err)
	}

	// Management: validated writes with optimistic concurrency.
	manager, err := featureflags.NewManager(store, featureflags.ManagerOptions{})
	if err != nil {
		log.Fatal(err)
	}
	flag := featureflags.NewBoolFlag("new-checkout", "Redesigned checkout flow")
	flag.Targets = []featureflags.Target{{Variant: featureflags.VariantOn, Tenants: []string{"acme"}}}
	flag.Rollout = featureflags.PercentageRollout(1000) // 10% of everyone else
	opsCtx := featureflags.WithActor(ctx, featureflags.Ref{Type: "user", ID: "ops@example.com"})
	if _, err := manager.Create(opsCtx, flag); err != nil {
		log.Print(err) // already exists on the second run
	}

	// Evaluation: an in-memory snapshot, refreshed by push and polling.
	listener, err := store.Listener(func(ctx context.Context) (*pgx.Conn, error) {
		return pgx.Connect(ctx, url)
	})
	if err != nil {
		log.Fatal(err)
	}
	client, err := featureflags.NewClient(store, featureflags.ClientOptions{Notifier: listener})
	if err != nil {
		log.Fatal(err)
	}
	if err := client.Refresh(ctx); err != nil {
		log.Fatal(err)
	}
	runCtx, stop := context.WithCancel(ctx)
	defer stop()
	go func() { _ = client.Run(runCtx) }()

	// Per request, usually in middleware after authentication:
	reqCtx := featureflags.WithEvalContext(ctx, featureflags.EvalContext{
		UserID:     "user-1",
		TenantID:   "acme",
		Attributes: map[string]any{"plan": "pro"},
	})
	r := client.Evaluate(reqCtx, "new-checkout")
	// The last argument is what a missing flag means to this caller.
	enabled := client.Enabled(reqCtx, "new-checkout", false)
	log.Printf("enabled=%v variant=%s reason=%s", enabled, r.Variant.Name, r.Reason)
}

Concepts

Flag. A key (a-z, 0-9, ., _, -; up to 128 characters), a description, an Enabled kill switch, named Variants with optional JSON payloads, and a DefaultVariant. Boolean flags use the variants on and off; Client.Enabled is true exactly when the result is on.

Evaluation order. For an enabled flag, targets and rules are checked in list order and the first match wins, so evaluation is deterministic:

Step Matches when Reason
Targets[i] the user ID or tenant ID is listed target_match, Index = i
Rules[i] every condition holds (and, for a distribution, the context has the bucketing key) rule_match, Index = i
Rollout the context has the bucketing key rollout
default always default

A disabled flag serves its default variant with reason disabled. An unknown key gives flag_not_found and the zero Variant; Client.Enabled and Client.Variant take the value to return in that case, so each caller decides what a missing flag means.

Conditions. eq, neq, in, not_in, contains (substring, or slice element) and the numeric lt, lte, gt, gte. Values are strings, booleans or numbers:

  • All Go numeric types compare as numbers (as float64). Kinds never mix: the string "12" is not the number 12, so eq and in fail and neq and not_in hold. Ordering operators hold only for numeric attributes.
  • String comparisons, contains included, are exact and case-sensitive.
  • A condition on an attribute the context does not carry, or carries as nil or as an unusable value (a map, NaN), never holds, including neq and not_in, so missing data never opts someone in.
  • There is no regular expression operator.

Bucketing. A Distribution assigns weights in basis points (summing to 10000) to variants and buckets by user ID (default) or tenant ID. The bucket of a context is part of the compatibility promise, because changing it would move users between variants of running rollouts:

input  = flagKey + 0x00 + salt + 0x00 + bucketingKey   (raw UTF-8 bytes, no normalization)
digest = SHA-256(input)
bucket = uint64(big-endian digest[0:8]) mod 10000

The variant listed at position i serves buckets from the sum of the weights before it (inclusive) to the sum including it (exclusive). So 0% serves nobody, 100% serves everybody, and growing the first variant's share only adds users: whoever was in at 10% is still in at 20%. The flag key is part of the hash, so each flag samples its own users; change the Salt to reshuffle one flag. Test vectors, pinned by TestBucketPinned and computed independently:

flag key salt bucketing key bucket
new-checkout user-1 1479
new-checkout user-2 8982
new-checkout v2 user-1 9307
pricing.page tenant-42 8132

Validation. Validate reports every problem at once: malformed keys or variant names, duplicate variants, invalid payload JSON, references to unknown variants, malformed conditions, and weights that do not sum to 10000. It also bounds the size of a definition, since definitions may come from an admin API and every request evaluates them: a 4096-byte description, 100 variants with 64 KiB payloads, 100 targets holding 10000 IDs in total, 100 rules of up to 20 conditions, 10000 in/not_in values in total, and 1024-byte condition strings. A definition that passes Validate never makes evaluation panic.

Safe defaults. Evaluation never touches the source, never blocks and never panics. A failing refresh keeps the last good snapshot and reports through ClientOptions.OnError. A stored definition that fails validation keeps the previous version of that flag (or leaves a new flag out) while every other flag still refreshes.

Hooks. ClientOptions.OnEvaluation receives every evaluation (for exposure analytics); ManagerOptions.OnChange receives every create, update and delete with the actor (for audit).

Context values. The evaluation context and the actor travel in the context through WithEvalContext and WithActor. An application that already keeps its user in the context supplies the lookups instead, once, at startup; the hooks have the same signatures as EvalContextFrom and ActorFrom:

featureflags.ClientOptions{
	EvalContextFrom: func(ctx context.Context) (featureflags.EvalContext, bool) {
		u, ok := myapp.UserFrom(ctx)
		return featureflags.EvalContext{UserID: u.ID, TenantID: u.OrgID}, ok
	},
}
featureflags.ManagerOptions{
	ActorFrom: func(ctx context.Context) (featureflags.Ref, bool) {
		u, ok := myapp.UserFrom(ctx)
		return featureflags.Ref{Type: "user", ID: u.ID}, ok
	},
}

An actor is a Ref, a type and an ID, so a user and an API token with the same ID never collide. A Ref with only one field set is rejected.

Errors. Every error wraps one of ErrNotFound, ErrInvalid or ErrConflict (through ErrFlagNotFound, ErrInvalidFlag, ErrFlagExists and ErrVersionConflict), so map them to HTTP statuses with errors.Is.

Config as code. ParseJSON and FileSource read {"flags": [...]} documents of up to 32 MiB strictly (unknown fields are errors). A FileSource can feed a Client directly, with polling picking up edits.

PostgreSQL

pgxstore stores one row per flag. Store.Schema() returns idempotent DDL; run it with CreateSchema or copy it into a migration:

CREATE TABLE IF NOT EXISTS "feature_flags" (
    key        text        PRIMARY KEY CHECK (key ~ '^[a-z0-9][a-z0-9._-]{0,127}$'),
    definition jsonb       NOT NULL CHECK (jsonb_typeof(definition) = 'object' AND definition->>'key' = key),
    version    bigint      NOT NULL CHECK (version >= 1),
    updated_at timestamptz NOT NULL
);

Options: WithTable("schema.table"), WithChannel(name) (the NOTIFY channel, default feature_flags), WithNow(clock) and WithConn.

Every write is a single conditional statement that also calls pg_notify. A second writer holding the same version waits for the first one's row lock and then fails with ErrVersionConflict, so concurrent updates never lose one another, and a write inside a transaction notifies only on commit. To make writes join a transaction you carry in the context, resolve the connection per call:

type txKey struct{}

store, err := pgxstore.New(pool, pgxstore.WithConn(func(ctx context.Context) pgxstore.Querier {
	if tx, ok := ctx.Value(txKey{}).(pgx.Tx); ok {
		return tx
	}
	return nil // use the pool
}))

A ManagerOptions.OnChange hook can then write its audit row with store.Querier(ctx) in the same transaction, and return an error to make the caller roll back.

A row edited by hand so that its definition no longer decodes does not stop the other flags: Load returns it without variants, so a running Client keeps that flag's previous definition and reports it, and Get returns the decode error.

store.Listener(connect) returns a featureflags.Notifier that holds one dedicated connection running LISTEN. It bounds connecting by WithConnectTimeout (10 seconds), pings an idle connection every WithPingInterval (30 seconds), reconnects with jittered exponential backoff (WithBackoff), reports through WithErrorHandler, and signals a refresh after every reconnect so nothing missed while disconnected is lost. Keep polling enabled as the safety net.

API overview

Package Contents
featureflags Flag, Variant, Target, Rule, Condition, Distribution; Validate, Bucket; Snapshot; Client (Enabled, Variant, Evaluate, Refresh, Run); Manager; Store, Source, Notifier; ParseJSON, FileSource; Ref, WithEvalContext, WithActor
memstore In-memory Store and Notifier (not transactional)
pgxstore PostgreSQL Store and LISTEN/NOTIFY Listener
storetest Run(t, newStore) conformance suite for Store implementations
featureflagstest New(t, opts) returns overridable flags behind a real Client

Testing

In application tests, use featureflagstest instead of a store:

flags := featureflagstest.New(t, featureflags.ClientOptions{})
flags.Enable("new-checkout")
flags.SetVariant("pricing-page", "annual-first", map[string]string{"highlight": "annual"})
handler := NewHandler(flags.Client())

Changes apply synchronously. To verify your own Store, run storetest.Run(t, newStore) from a test.

To test this module, see CONTRIBUTING.md; the PostgreSQL tests need TEST_DATABASE_URL.

Roadmap

  • Targeted refresh: reload only the flag named in a notification instead of the full set.
  • Segments: reusable, named groups of conditions shared across flags.
  • Semantic version and date comparison operators.
  • Prerequisite flags (a flag that only applies when another resolves to a given variant).
  • Syncing a config-as-code file into a Store through the Manager.
  • Flag history (previous versions) in pgxstore.

Stability

This package follows semantic versioning. Before v1.0.0, a minor release may contain breaking changes; each one is listed in CHANGELOG.md with upgrade steps, including SQL for schema changes. From v1.0.0, the exported API and the PostgreSQL schema change only in backwards-compatible ways until the next major version.

License

MIT

About

Feature flags and gradual rollouts for Go: targeting rules, sticky percentage rollouts, variants, and in-memory evaluation with Postgres push updates.

Topics

Resources

Contributing

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages