mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-09 07:29:35 +02:00
* feat(featureflag): framework-level feature flag system (MUL-3615) Introduces a reusable feature flag framework so future features can adopt flags without writing infrastructure code. Backend: server/pkg/featureflag (Go) - Service / Provider / Decision separation per Martin Fowler's Toggle Point / Toggle Router / Toggle Configuration pattern. - Providers: StaticProvider (rules in source control), EnvProvider (FF_<KEY> overrides for ops kill switches), ChainProvider (first-hit-wins composition). - EvalContext carried through context.Context with WithEvalContext / EvalContextFrom; supports user_id, workspace_id, free-form attributes. - PercentRollout via deterministic FNV-1a bucketing; same user always lands in the same bucket so experiments do not flap between requests. - Nil-safe Service: a nil *Service or missing flag returns the caller's default so business code never panics on a missing flag. - 100% unit-test coverage with -race; go vet clean. Frontend: packages/core/feature-flags (TypeScript) - Same vocabulary as the Go side (Decision, EvalContext, Rule, PercentRollout). FNV-1a parity ensures cross-tier bucket agreement. - FeatureFlagService + StaticProvider + ChainProvider in pure TS. - React glue: FeatureFlagsProvider, useFlag(key, default), useVariant(key, default). Hooks fall back to the default when no provider is mounted so Storybook / unit tests stay simple. - Vitest tests for service, providers, hash, and React hooks. Docs: docs/feature-flags.md — wiring, EvalContext, toggle points, backend-protection note, and the standard best-practice checklist. The framework intentionally has no third-party Go deps and no API surface beyond what real callers will need. New providers (DB, remote config, LaunchDarkly) plug in by implementing Provider; no existing caller has to change. Co-authored-by: multica-agent <github@multica.ai> * fix(featureflag): cross-tier hash parity + variant only when enabled (MUL-3615) Two must-fix issues from the PR review on #4496: 1. TS hash had a trailing zero separator that Go did not emit, so the same (key, identifier) bucketed differently on the two tiers. The "user lands in the same bucket on server and client" promise was broken. For example billing_new_invoice/user-42 was bucket 97 in Go and bucket 11 in TS. Fix: TS fnv1a now emits the zero separator BETWEEN parts only, never after the last one, matching Go's hash.Write byte stream exactly. Verified by parallel golden tests on both sides that pin five (key, identifier) -> bucket triples; if either side drifts both tests fail and one must be brought back in sync. 2. StaticProvider returned `Rule.Variant` regardless of whether the rule evaluated to enabled=true. A 0%-rollout user, a deny-listed user, or a default-off user would see variant="experiment-v2", so callers branching on Variant() would route control users into the experiment arm. Fix: Rule.Variant is now the ON-variant only. When the rule evaluates to enabled=false the Decision's variant is the canonical "off", regardless of what Rule.Variant says. Documented as a behavior contract in the Rule godoc / JSDoc and covered by regression tests on both sides. Tests: - go test -race ./pkg/featureflag/... : all green (1.58s). - pnpm --filter @multica/core test : 661/661 (3 new). - pnpm --filter @multica/core typecheck: clean. Co-authored-by: multica-agent <github@multica.ai> * fix(featureflag): hash UTF-8 bytes on the TS side for cross-tier parity (MUL-3615) Follow-up review on PR #4496 caught that the previous hash fix was only correct for ASCII input. The TS side used `charCodeAt`, which returns UTF-16 code units, while the Go side hashes the UTF-8 byte representation. Any non-ASCII flag key or identifier — Chinese flag names, accented user IDs, emoji — would bucket differently on backend vs frontend, silently breaking the "same user, same bucket" promise the PR description makes. Concretely: flag/é Go 53 vs TS-old 68 flag/🦄 Go 82 vs TS-old 75 实验/user-1 Go 90 vs TS-old 4 flag/用户-1 Go 95 vs TS-old 2 Fix: replace per-char charCodeAt with a module-level `TextEncoder` ('utf-8') and hash each encoded byte. After the fix all four cases above match Go exactly, and the existing ASCII cases continue to match. The cross-language golden tables on both sides now include the 5 new non-ASCII cases alongside the 5 ASCII cases, so any future regression that swaps UTF-8 for charCodeAt (or vice versa) will fail loudly on both Go and TS simultaneously. TextEncoder is part of WHATWG Encoding and is available in every evergreen browser, in Node 11+, and in Hermes (React Native) >= 0.74, which covers every runtime that imports @multica/core/feature-flags. Tests: - go test -race ./pkg/featureflag/... : all green. - pnpm --filter @multica/core test : 661/661. - pnpm --filter @multica/core typecheck : clean. Co-authored-by: multica-agent <github@multica.ai> * feat(featureflag): wire into main app config — YAML file + env override (MUL-3615) Follow-up requested by Yushen on PR #4496: make the feature flag framework configurable through the existing main-program config system instead of requiring Go code edits. multica's main app is purely env-var driven (see .env.example) with optional MULTICA_*_FILE knobs for richer config; feature flags now follow the same pattern. server/pkg/featureflag/config.go - LoadRulesFromYAMLFile(path) parses a YAML rule set into runtime Rule structs. Empty files are a valid "no flags yet" state; missing or malformed files surface a hard error so operators see misconfig the same way DATABASE_URL parse errors do. - NewServiceFromEnv composes the standard provider chain: 1. EnvProvider("FF_") (runtime kill-switch path) 2. StaticProvider from YAML file (declarative rule set) When MULTICA_FEATURE_FLAGS_FILE is unset, only the env layer is active and every IsEnabled call falls through to the caller's default, so the server can boot before any flag is authored. server/cmd/server/main.go - Construct the Service once at startup right after env-var warnings, fail loudly on malformed YAML, log the loaded rule count via the Service logger. The Service is held in a local `flags` variable ready to be threaded into handler.Handler / service constructors when the first flag user lands. Threading is deferred to the PR that adds the first business consumer so this PR stays a pure framework + config layer. .env.example - New "Feature flags" section documents MULTICA_FEATURE_FLAGS_FILE and the FF_<KEY> override convention, with a minimal YAML schema example inline. docs/feature-flags.md - Replace the "build a provider manually" example with the NewServiceFromEnv pattern that now matches what main.go actually does. Show the YAML schema in one place. Note the on-variant / off semantics from the previous review round. server/pkg/featureflag/doc.go - Update package doc to mention the gopkg.in/yaml.v3 dependency (already a server-level dep) instead of the now-inaccurate "no third-party dependencies" claim. Tests: - go test -race -count=1 ./pkg/featureflag/... all green; new config_test.go covers: simple YAML, full-shape YAML, empty file, missing file, malformed YAML, no env var, file-only, env-beats-file, bad file surfaces error. - go test -race -count=1 -run TestHealth ./cmd/server/... sanity check that the main.go boot path with the new wiring still passes. - go vet ./... clean. Co-authored-by: multica-agent <github@multica.ai> --------- Co-authored-by: Eve <eve@multica-ai.local> Co-authored-by: multica-agent <github@multica.ai>
212 lines
7.1 KiB
Go
212 lines
7.1 KiB
Go
package featureflag
|
|
|
|
import (
|
|
"context"
|
|
"slices"
|
|
"sync"
|
|
)
|
|
|
|
// Rule describes how a single flag is evaluated by the StaticProvider.
|
|
// All fields are optional; an empty Rule evaluates to Default (false) for
|
|
// everyone.
|
|
//
|
|
// Evaluation order (first match wins):
|
|
//
|
|
// 1. Deny: if any value in the EvalContext matches an entry in Deny on
|
|
// attribute DenyBy (default "user_id"), the flag is OFF.
|
|
// 2. Allow: if any value matches an entry in Allow on attribute AllowBy
|
|
// (default "user_id"), the flag is ON.
|
|
// 3. Percent: if Percent is non-nil and the bucket for (key, identifier)
|
|
// falls inside Percent.Percent, the flag is ON.
|
|
// 4. Default: returned otherwise.
|
|
//
|
|
// Allow / Deny lists are intentionally separate (rather than a single
|
|
// targeting predicate) because operationally they cover different use
|
|
// cases — Allow is "internal users only" and Deny is "kill switch for
|
|
// these tenants" — and keeping them separate makes the data easy to audit
|
|
// in source control.
|
|
type Rule struct {
|
|
// Default is the value returned when no targeting rule matches.
|
|
Default bool
|
|
|
|
// Variant is the variant identifier returned WHEN the rule evaluates
|
|
// to enabled=true. For multi-arm experiments, set Variant to the
|
|
// experiment-arm identifier (e.g. "experiment-v2"); for plain on/off
|
|
// flags leave it empty.
|
|
//
|
|
// When the rule evaluates to enabled=false (default-off, deny hit,
|
|
// percent miss, ...) the resulting Decision's Variant is always the
|
|
// canonical "off". This is deliberate: a caller that branches on
|
|
// Variant("checkout_algo", "control") would otherwise be routed into
|
|
// the experiment arm even though the user did not roll into the
|
|
// experiment cohort.
|
|
Variant string
|
|
|
|
// Allow is the set of identifier values that force the flag ON.
|
|
Allow []string
|
|
|
|
// AllowBy is the EvalContext attribute name used for Allow lookups.
|
|
// Defaults to "user_id" when empty.
|
|
AllowBy string
|
|
|
|
// Deny is the set of identifier values that force the flag OFF.
|
|
// Deny wins over Allow.
|
|
Deny []string
|
|
|
|
// DenyBy is the EvalContext attribute name used for Deny lookups.
|
|
// Defaults to "user_id" when empty.
|
|
DenyBy string
|
|
|
|
// Percent enables a deterministic percent rollout. When nil, no
|
|
// percent rollout is applied and Default is used as the fallback.
|
|
Percent *PercentRollout
|
|
}
|
|
|
|
// PercentRollout describes a deterministic percent rollout.
|
|
//
|
|
// The bucket is computed from (flag key, EvalContext attribute By) using
|
|
// FNV-1a, which guarantees that the same identifier always falls into the
|
|
// same bucket across processes and across restarts. This is what callers
|
|
// need so users do not flip in and out of an experiment between requests.
|
|
type PercentRollout struct {
|
|
// Percent is the rollout size in [0, 100]. 0 disables the rollout;
|
|
// 100 enables it for everyone. Out-of-range values are clamped.
|
|
Percent int
|
|
|
|
// By selects the EvalContext attribute used as the bucketing
|
|
// identifier. Defaults to "user_id". Use "workspace_id" for
|
|
// workspace-scoped rollouts.
|
|
By string
|
|
}
|
|
|
|
// StaticProvider is a thread-safe in-memory Provider populated either
|
|
// programmatically or from a config file. It is the recommended baseline
|
|
// provider for production: configuration lives in source control, moves
|
|
// through CD alongside the binary, and changes require a deploy — which is
|
|
// exactly the Continuous Delivery posture Martin Fowler recommends for
|
|
// Release Toggles and most Permissioning Toggles.
|
|
//
|
|
// For dynamic flags (kill switches, A/B tests changed by product) compose
|
|
// a StaticProvider with a DB-backed Provider behind a ChainProvider.
|
|
type StaticProvider struct {
|
|
mu sync.RWMutex
|
|
rules map[string]Rule
|
|
}
|
|
|
|
// NewStaticProvider returns an empty StaticProvider. Use Set or
|
|
// LoadRules to populate it.
|
|
func NewStaticProvider() *StaticProvider {
|
|
return &StaticProvider{rules: map[string]Rule{}}
|
|
}
|
|
|
|
// Name implements Provider.
|
|
func (*StaticProvider) Name() string { return "static" }
|
|
|
|
// Set installs or replaces the rule for key. Concurrent callers are
|
|
// serialized; readers (Lookup) never block writers for long.
|
|
func (p *StaticProvider) Set(key string, rule Rule) {
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.rules[key] = rule
|
|
}
|
|
|
|
// LoadRules atomically replaces every rule in the provider with the supplied
|
|
// map. Use this when reloading from a config file: a partial reload could
|
|
// otherwise leave the provider in a mixed state where some flags reflect the
|
|
// new config and others the old.
|
|
func (p *StaticProvider) LoadRules(rules map[string]Rule) {
|
|
clone := make(map[string]Rule, len(rules))
|
|
for k, v := range rules {
|
|
clone[k] = v
|
|
}
|
|
p.mu.Lock()
|
|
defer p.mu.Unlock()
|
|
p.rules = clone
|
|
}
|
|
|
|
// Keys returns the sorted set of flag keys this provider knows about. Useful
|
|
// for diagnostic endpoints. The returned slice is a copy; mutating it does
|
|
// not affect the provider.
|
|
func (p *StaticProvider) Keys() []string {
|
|
p.mu.RLock()
|
|
defer p.mu.RUnlock()
|
|
out := make([]string, 0, len(p.rules))
|
|
for k := range p.rules {
|
|
out = append(out, k)
|
|
}
|
|
slices.Sort(out)
|
|
return out
|
|
}
|
|
|
|
// Lookup implements Provider.
|
|
func (p *StaticProvider) Lookup(ctx context.Context, key string) (Decision, bool) {
|
|
p.mu.RLock()
|
|
rule, ok := p.rules[key]
|
|
p.mu.RUnlock()
|
|
if !ok {
|
|
return Decision{}, false
|
|
}
|
|
ec := EvalContextFrom(ctx)
|
|
return evaluateRule(key, rule, ec), true
|
|
}
|
|
|
|
func evaluateRule(key string, rule Rule, ec EvalContext) Decision {
|
|
// Deny wins over everything else. A kill switch must be reachable
|
|
// even when other targeting matches.
|
|
denyBy := orDefault(rule.DenyBy, "user_id")
|
|
if len(rule.Deny) > 0 {
|
|
if v, ok := ec.Lookup(denyBy); ok && slices.Contains(rule.Deny, v) {
|
|
return decisionFromRule(key, rule, false, ReasonStatic)
|
|
}
|
|
}
|
|
|
|
allowBy := orDefault(rule.AllowBy, "user_id")
|
|
if len(rule.Allow) > 0 {
|
|
if v, ok := ec.Lookup(allowBy); ok && slices.Contains(rule.Allow, v) {
|
|
return decisionFromRule(key, rule, true, ReasonStatic)
|
|
}
|
|
}
|
|
|
|
if rule.Percent != nil {
|
|
by := orDefault(rule.Percent.By, "user_id")
|
|
identifier, _ := ec.Lookup(by)
|
|
// An empty identifier still produces a deterministic bucket
|
|
// (the empty string hashes to a stable bucket) but in practice
|
|
// that means everyone-without-an-id lands in the same bucket.
|
|
// That's the desired behavior for percent rollouts at the edge:
|
|
// anonymous users get a single shared rollout decision per
|
|
// flag, not a uniformly random one.
|
|
if inPercent(key, identifier, rule.Percent.Percent) {
|
|
return decisionFromRule(key, rule, true, ReasonPercent)
|
|
}
|
|
return decisionFromRule(key, rule, false, ReasonPercent)
|
|
}
|
|
|
|
return decisionFromRule(key, rule, rule.Default, ReasonStatic)
|
|
}
|
|
|
|
func decisionFromRule(key string, rule Rule, enabled bool, reason Reason) Decision {
|
|
// Variant policy: rule.Variant is the ON-variant. When the rule
|
|
// evaluates to false we return the canonical "off" so a caller
|
|
// branching on Variant() cannot accidentally enter the experiment
|
|
// arm for a user that did not roll in.
|
|
variant := boolToVariant(enabled)
|
|
if enabled && rule.Variant != "" {
|
|
variant = rule.Variant
|
|
}
|
|
return Decision{
|
|
Key: key,
|
|
Enabled: enabled,
|
|
Variant: variant,
|
|
Reason: reason,
|
|
Source: "static",
|
|
}
|
|
}
|
|
|
|
func orDefault(v, def string) string {
|
|
if v == "" {
|
|
return def
|
|
}
|
|
return v
|
|
}
|