feature-flag 0.1.0

Server-side feature flag evaluation for async Rust: targeting rules, sticky percentage rollouts, hot reload, zero RNG.
Documentation

feature-flag

CI Rust License: MIT

Server-side feature flag evaluation for async Rust services. Targeting rules, sticky percentage rollouts, hot reload from a JSON file, zero RNG. Built around Tokio, but the evaluation path is sync — flags don't need to await.

use feature_flag::{FlagEvaluator, FlagSet, Subject};

let flagset = FlagSet::from_json(include_str!("../examples/flagset.json"))?;
let evaluator = FlagEvaluator::new(flagset);

let subject = Subject::new("user-42")
    .with_attr("country", "US")
    .with_attr("plan", "pro");

let result = evaluator.evaluate("new-checkout-flow", &subject)?;
println!("variant: {} (rule: {:?})", result.variant, result.matched_rule_id);

Design rules

  1. No external RNG. Sticky bucketing is SHA-256(flag_id ‖ "/" ‖ subject_id) mod 100. Deterministic; no entropy source needed; the same user always lands in the same slot.
  2. Tokio is the assumed runtime for I/O paths (hot reload). The evaluation hot path is plain sync — flags don't need an executor.
  3. Cheap clones. FlagEvaluator is Arc-shaped, so handing it across tasks is free.
  4. Snapshot for tests. Build a FlagSet in code, evaluate, assert. No I/O required.
  5. Strict validation up-front. Rollout weights must sum to 100. Rules can't reference variants the flag doesn't declare. FlagSet::from_json runs both checks before returning.

The DSL

A FlagSet is JSON, like this:

{
  "version": "demo-1",
  "flags": [
    {
      "id": "new-checkout-flow",
      "variants": ["control", "treatment"],
      "default_variant": "control",
      "rules": [
        {
          "id": "internal-employees",
          "when": {
            "kind": "compare",
            "attr": "email",
            "op": "ends_with",
            "value": "@kineticgain.com"
          },
          "outcome": {"kind": "variant", "variant": "treatment"}
        },
        {
          "id": "us-ca-30pct",
          "when": {
            "kind": "compare",
            "attr": "country",
            "op": "in",
            "value": ["US", "CA"]
          },
          "outcome": {
            "kind": "rollout",
            "variants": [
              {"variant": "control", "weight": 70},
              {"variant": "treatment", "weight": 30}
            ]
          }
        }
      ]
    }
  ]
}

Rules are evaluated in declared order. The first matching rule wins; its outcome is either a fixed variant or a sticky rollout.

Predicate operators

kind Notes
always Always matches. Useful as a default.
compare + op: eq / ne Strict JSON-value equality.
compare + op: gt / gte / lt / lte Numeric. false on non-numeric types.
compare + op: in / not_in value must be an array.
compare + op: starts_with / ends_with String-only.
all_of / any_of / not Combinators. Recursive.

enabled: false is the kill switch

When enabled is false the evaluator skips all rules and returns default_variant with reason: "disabled". Set it from a remote config push to cut traffic to a feature without redeploying.


Hot reload

Watch a JSON file and atomically swap the loaded FlagSet whenever the mtime advances. Bad pushes don't kill the watcher — they log and stick with the previously-good set.

use std::time::Duration;
use feature_flag::{FlagEvaluator, FlagSet, HotReloader};

let evaluator = FlagEvaluator::new(FlagSet::default());
let reloader = HotReloader::spawn(
    "/etc/feature-flags.json",
    evaluator.clone(),
    Duration::from_secs(15),
).await?;

// ... your service runs ...

reloader.shutdown().await;

Polling cadence is intentional: no platform-specific code, no extra dep tree, and flag reload doesn't need millisecond latency.


Sticky rollout — why SHA-256 instead of a hash like FNV

Two reasons. First, the keyspace is open (user-supplied flag ids + subject ids), so we want a hash that doesn't accidentally collide on adversarial inputs. Second, SHA-256 is already in every dependency tree, the cost (~hundreds of ns) is invisible next to a feature-flag dispatch path. If you ever need to migrate to a different hash, the bucketing function is one private helper — replace and re-run the tests.


Composes with

  • reliability-toolkit-rs — wrap a rollout-controlled call in a circuit breaker + retry.
  • slo-budget-tracker — flip a flag to enabled: false from a remote config push when your SLO starts burning.
  • policy-as-code-engine — for the policy side of "should this happen" (allow/deny on identity + action), pair with this crate which answers "which behaviour should we run."

Examples

cargo run --example evaluate
# subject=alice    country=US  -> "control" (rule="us-ca-rollout-30pct", reason=rule_match)
# subject=bob      country=DE  -> "control" (rule="-", reason=default)
# subject=carla    country=US  -> "treatment" (rule="internal-employees", reason=rule_match)

Tests + bench

cargo test --all-targets
cargo test --doc
cargo clippy --all-targets -- -Dwarnings
cargo fmt --all -- --check
cargo bench

The CI matrix is stable, beta, and 1.85.0 (MSRV).


License

MIT. See LICENSE.