Skip to content
This repository has been archived by the owner on Jun 21, 2024. It is now read-only.

Commit

Permalink
build basic flag matching
Browse files Browse the repository at this point in the history
  • Loading branch information
neilkakkar committed May 29, 2024
1 parent d040b94 commit 278fab7
Show file tree
Hide file tree
Showing 6 changed files with 2,339 additions and 10 deletions.
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions feature-flags/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
serde-pickle = { version = "1.1.1"}
hex = "0.4.3"
sha1 = "0.10.6"

[lints]
workspace = true
Expand Down
32 changes: 23 additions & 9 deletions feature-flags/src/flag_definitions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ pub enum GroupTypeIndex {

}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub enum OperatorType {
#[serde(rename = "exact")]
Exact,
Expand Down Expand Up @@ -52,7 +52,7 @@ pub enum OperatorType {
IsDateBefore,
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct PropertyFilter {
pub key: String,
pub value: serde_json::Value,
Expand All @@ -62,29 +62,29 @@ pub struct PropertyFilter {
pub group_type_index: Option<u8>,
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlagGroupType {
pub properties: Option<Vec<PropertyFilter>>,
pub rollout_percentage: Option<f32>,
pub rollout_percentage: Option<f64>,
pub variant: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MultivariateFlagVariant {
pub key: String,
pub name: Option<String>,
pub rollout_percentage: f32,
pub rollout_percentage: f64,
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct MultivariateFlagOptions {
pub variants: Vec<MultivariateFlagVariant>,
}

// TODO: test name with https://www.fileformat.info/info/charset/UTF-16/list.htm values, like '𝖕𝖗𝖔𝖕𝖊𝖗𝖙𝖞': `𝓿𝓪𝓵𝓾𝓮`


#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FlagFilters {
pub groups: Vec<FlagGroupType>,
pub multivariate: Option<MultivariateFlagOptions>,
Expand All @@ -93,7 +93,7 @@ pub struct FlagFilters {
pub super_groups: Option<Vec<FlagGroupType>>,
}

#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct FeatureFlag {
pub id: i64,
pub team_id: i64,
Expand All @@ -108,6 +108,20 @@ pub struct FeatureFlag {
pub ensure_experience_continuity: bool,
}

impl FeatureFlag {
pub fn get_group_type_index(&self) -> Option<u8> {
self.filters.aggregation_group_type_index
}

pub fn get_conditions(&self) -> &Vec<FlagGroupType> {
&self.filters.groups
}

pub fn get_variants(&self) -> Vec<MultivariateFlagVariant> {
self.filters.multivariate.clone().map_or(vec![], |m| m.variants)
}
}

#[derive(Debug, Deserialize, Serialize)]

pub struct FeatureFlagList {
Expand Down
140 changes: 140 additions & 0 deletions feature-flags/src/flag_matching.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
use sha1::{Digest, Sha1};

use crate::flag_definitions::{FeatureFlag, FlagGroupType};

#[derive(Debug, PartialEq, Eq)]
pub struct FeatureFlagMatch {
pub matches: bool,
pub variant: Option<String>,
//reason
//condition_index
//payload
}

#[derive(Debug)]
pub struct FeatureFlagMatcher {
// pub flags: Vec<FeatureFlag>,
pub distinct_id: String,
}

const LONG_SCALE: u64 = 0xfffffffffffffff;

impl FeatureFlagMatcher {

pub fn new(distinct_id: String) -> Self {
FeatureFlagMatcher {
// flags,
distinct_id,
}
}

pub fn get_match(&self, feature_flag: &FeatureFlag) -> FeatureFlagMatch {

if self.hashed_identifier(feature_flag).is_none() {
return FeatureFlagMatch {
matches: false,
variant: None,
};
}

// TODO: super groups
// TODO: Variant overrides condition sort

for (index, condition) in feature_flag.get_conditions().iter().enumerate() {
let (is_match, evaluation_reason) = self.is_condition_match(feature_flag, condition, index);

if is_match {

let variant = match condition.variant.clone() {
Some(variant_override) => {
if feature_flag.get_variants().iter().any(|v| v.key == variant_override) {
Some(variant_override)
} else {
self.get_matching_variant(feature_flag)
}
}
None => {
self.get_matching_variant(feature_flag)
}
};

// let payload = self.get_matching_payload(is_match, variant, feature_flag);
return FeatureFlagMatch {
matches: true,
variant,
};
}
}
FeatureFlagMatch {
matches: false,
variant: None,
}
}

pub fn is_condition_match(&self, feature_flag: &FeatureFlag, condition: &FlagGroupType, _index: usize) -> (bool, String) {
let rollout_percentage = condition.rollout_percentage.unwrap_or(100.0);
let mut condition_match = true;
if condition.properties.is_some() {
// TODO: Handle matching conditions
if !condition.properties.as_ref().unwrap().is_empty() {
condition_match = false;
}
}

if !condition_match {
return (false, "NO_CONDITION_MATCH".to_string());
} else if rollout_percentage == 100.0 {
// TODO: Check floating point schenanigans if any
return (true, "CONDITION_MATCH".to_string());
}

if self.get_hash(feature_flag, "") > (rollout_percentage / 100.0) {
return (false, "OUT_OF_ROLLOUT_BOUND".to_string());
}

(true, "CONDITION_MATCH".to_string())

}

pub fn hashed_identifier(&self, feature_flag: &FeatureFlag) -> Option<String> {
if feature_flag.get_group_type_index().is_none() {
// TODO: Use hash key overrides for experience continuity
Some(self.distinct_id.clone())
} else {
// TODO: Handle getting group key
Some("".to_string())
}
}

/// This function takes a identifier and a feature flag key and returns a float between 0 and 1.
/// Given the same identifier and key, it'll always return the same float. These floats are
/// uniformly distributed between 0 and 1, so if we want to show this feature to 20% of traffic
/// we can do _hash(key, identifier) < 0.2
pub fn get_hash(&self, feature_flag: &FeatureFlag, salt: &str) -> f64 {
// check if hashed_identifier is None
let hashed_identifier = self.hashed_identifier(feature_flag).expect("hashed_identifier is None when computing hash");
let hash_key = format!("{}.{}{}", feature_flag.key, hashed_identifier, salt);
let mut hasher = Sha1::new();
hasher.update(hash_key.as_bytes());
let result = hasher.finalize();
// :TRICKY: Convert the first 15 characters of the digest to a hexadecimal string
// not sure if this is correct, padding each byte as 2 characters
let hex_str: String = result.iter().map(|byte| format!("{:02x}", byte)).collect::<String>()[..15].to_string();
let hash_val = u64::from_str_radix(&hex_str, 16).unwrap();

hash_val as f64 / LONG_SCALE as f64
}

pub fn get_matching_variant(&self, feature_flag: &FeatureFlag) -> Option<String> {
let hash = self.get_hash(feature_flag, "variant");
let mut total_percentage = 0.0;

for variant in feature_flag.get_variants() {
total_percentage += variant.rollout_percentage / 100.0;
if hash < total_percentage {
return Some(variant.key.clone());
}
}
None
}
}
34 changes: 33 additions & 1 deletion feature-flags/src/test_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ use serde_json::json;
use std::sync::Arc;

use crate::{
flag_definitions, redis::{Client, RedisClient}, team::{self, Team}
flag_definitions::{self, FeatureFlag}, redis::{Client, RedisClient}, team::{self, Team}
};
use rand::{distributions::Alphanumeric, Rng};

Expand Down Expand Up @@ -89,3 +89,35 @@ pub fn setup_redis_client(url: Option<String>) -> Arc<RedisClient> {
let client = RedisClient::new(redis_url).expect("Failed to create redis client");
Arc::new(client)
}

pub fn create_flag_from_json(json_value: Option<String>) -> Vec<FeatureFlag> {

let payload = match json_value {
Some(value) => value,
None => json!([{
"id": 1,
"key": "flag1",
"name": "flag1 description",
"active": true,
"deleted": false,
"team_id": 1,
"filters": {
"groups": [
{
"properties": [
{
"key": "email",
"value": "[email protected]",
"type": "person",
},
],
"rollout_percentage": 50,
},
],
},
}]).to_string(),
};

let flags: Vec<FeatureFlag> = serde_json::from_str(&payload).expect("Failed to parse data to flags list");
flags
}
Loading

0 comments on commit 278fab7

Please sign in to comment.