oai-statsig-rust 0.27.0

Statsig Rust SDK for usage in multi-user server environments.
Documentation
use std::collections::{HashMap, HashSet};

use crate::{
    evaluation::{evaluation_data::SpecView, evaluator::SpecType},
    hashing::{HashAlgorithm, HashUtil},
    interned_string::InternedString,
    specs_response::{
        cmab_types::CMABConfig, spec_types::SpecsResponseFull, specs_hash_map::SpecsHashMap,
    },
};

#[derive(Debug)]
pub struct GcirEvaluationPlan {
    pub(crate) feature_gates: Vec<PlannedEvaluation>,
    pub(crate) dynamic_configs: Vec<PlannedEvaluation>,
    pub(crate) cmab_configs: Vec<PlannedCmabEvaluation>,
    pub(crate) layer_configs: Vec<PlannedEvaluation>,
}

#[derive(Debug)]
pub(crate) struct PlannedEvaluation {
    pub(crate) name: InternedString,
    pub(crate) entity: InternedString,
    pub(crate) spec_type: SpecType,
    pub(crate) target_app_ids: Option<Vec<InternedString>>,
    pub(crate) is_experiment_in_layer: bool,
    hashed_djb2: InternedString,
    hashed_sha256: InternedString,
}

#[derive(Debug)]
pub(crate) struct PlannedCmabEvaluation {
    pub(crate) name: InternedString,
    hashed_djb2: InternedString,
    hashed_sha256: InternedString,
}

impl GcirEvaluationPlan {
    #[must_use]
    pub fn new(specs: &SpecsResponseFull, hashing: &HashUtil) -> Self {
        let pipeline_override_names = get_pipeline_override_names(specs);
        Self {
            feature_gates: build_spec_plan(
                &specs.feature_gates,
                &pipeline_override_names,
                &specs.experiment_to_layer,
                hashing,
                |_| SpecType::Gate,
            ),
            dynamic_configs: build_spec_plan(
                &specs.dynamic_configs,
                &pipeline_override_names,
                &specs.experiment_to_layer,
                hashing,
                dynamic_config_type,
            ),
            cmab_configs: build_cmab_plan(specs.cmab_configs.as_ref(), hashing),
            layer_configs: build_spec_plan(
                &specs.layer_configs,
                &pipeline_override_names,
                &specs.experiment_to_layer,
                hashing,
                |_| SpecType::Layer,
            ),
        }
    }

    pub(crate) fn total_evaluation_count(&self) -> usize {
        self.feature_gates.len()
            + self.dynamic_configs.len()
            + self.cmab_configs.len()
            + self.layer_configs.len()
    }
}

impl PlannedEvaluation {
    pub(crate) fn hashed_name(&self, hash_algorithm: &HashAlgorithm) -> &InternedString {
        match hash_algorithm {
            HashAlgorithm::Djb2 => &self.hashed_djb2,
            HashAlgorithm::None => &self.name,
            HashAlgorithm::Sha256 => &self.hashed_sha256,
        }
    }
}

impl PlannedCmabEvaluation {
    pub(crate) fn hashed_name(&self, hash_algorithm: &HashAlgorithm) -> &InternedString {
        match hash_algorithm {
            HashAlgorithm::Djb2 => &self.hashed_djb2,
            HashAlgorithm::None => &self.name,
            HashAlgorithm::Sha256 => &self.hashed_sha256,
        }
    }
}

fn build_spec_plan(
    specs_map: &SpecsHashMap,
    pipeline_override_names: &HashSet<InternedString>,
    experiment_to_layer: &HashMap<String, String>,
    hashing: &HashUtil,
    get_spec_type: impl Fn(SpecView<'_>) -> SpecType,
) -> Vec<PlannedEvaluation> {
    let mut keys = specs_map.keys().cloned().collect::<Vec<_>>();
    keys.sort_unstable_by(|a, b| a.as_str().cmp(b.as_str()));

    keys.into_iter()
        .filter_map(|name| {
            let spec = specs_map.get(&name)?.view();
            let entity = spec.entity();
            if entity.as_str() == "segment"
                || entity.as_str() == "holdout"
                || pipeline_override_names.contains(&name)
            {
                return None;
            }

            let is_experiment_in_layer =
                entity.as_str() == "experiment" && experiment_to_layer.contains_key(name.as_str());

            Some(PlannedEvaluation {
                hashed_djb2: InternedString::from_string_uninterned(
                    hashing.hash(name.as_str(), &HashAlgorithm::Djb2),
                ),
                hashed_sha256: InternedString::from_string_uninterned(
                    hashing.hash(name.as_str(), &HashAlgorithm::Sha256),
                ),
                name,
                entity: entity.to_interned(),
                spec_type: get_spec_type(spec),
                target_app_ids: spec.target_app_ids(),
                is_experiment_in_layer,
            })
        })
        .collect()
}

fn build_cmab_plan(
    cmab_configs: Option<&std::collections::HashMap<String, CMABConfig>>,
    hashing: &HashUtil,
) -> Vec<PlannedCmabEvaluation> {
    let Some(cmab_configs) = cmab_configs else {
        return Vec::new();
    };

    let mut keys = cmab_configs.keys().cloned().collect::<Vec<_>>();
    keys.sort_unstable();

    keys.into_iter()
        .map(|name| PlannedCmabEvaluation {
            hashed_djb2: InternedString::from_string_uninterned(
                hashing.hash(name.as_str(), &HashAlgorithm::Djb2),
            ),
            hashed_sha256: InternedString::from_string_uninterned(
                hashing.hash(name.as_str(), &HashAlgorithm::Sha256),
            ),
            name: InternedString::from_string_uninterned(name),
        })
        .collect()
}

fn dynamic_config_type(spec: SpecView<'_>) -> SpecType {
    if spec.entity().as_str() == "dynamic_config" {
        SpecType::DynamicConfig
    } else {
        SpecType::Experiment
    }
}

fn get_pipeline_override_names(specs: &SpecsResponseFull) -> HashSet<InternedString> {
    specs
        .overrides
        .as_ref()
        .map(|overrides| {
            overrides
                .values()
                .flat_map(|mappings| {
                    mappings
                        .iter()
                        .map(|mapping| mapping.new_config_name.clone())
                })
                .collect()
        })
        .unwrap_or_default()
}

#[cfg(test)]
mod tests {
    use super::GcirEvaluationPlan;
    use crate::{
        dcs_str::DCS_STR, hashing::HashUtil, interned_string::InternedStringValue,
        specs_response::spec_types::SpecsResponseFull,
    };

    #[test]
    fn generated_response_names_do_not_use_the_global_interner() {
        let specs: SpecsResponseFull = serde_json::from_str(DCS_STR).unwrap();
        let plan = GcirEvaluationPlan::new(&specs, &HashUtil::new());

        for (planned, source) in [
            (&plan.feature_gates, &specs.feature_gates),
            (&plan.dynamic_configs, &specs.dynamic_configs),
            (&plan.layer_configs, &specs.layer_configs),
        ] {
            for evaluation in planned {
                let original = source
                    .keys()
                    .find(|name| name.as_str() == evaluation.name.as_str())
                    .unwrap();
                match (&evaluation.name.value, &original.value) {
                    (InternedStringValue::Pointer(left), InternedStringValue::Pointer(right))
                    | (
                        InternedStringValue::Uninterned(left),
                        InternedStringValue::Uninterned(right),
                    ) => {
                        assert!(std::sync::Arc::ptr_eq(left, right));
                    }
                    (InternedStringValue::Static(left), InternedStringValue::Static(right)) => {
                        assert!(std::ptr::eq(*left, *right));
                    }
                    (left, right) => {
                        panic!("planned name changed representation: {left:?} vs {right:?}")
                    }
                }
            }
        }

        for planned in plan
            .feature_gates
            .iter()
            .chain(&plan.dynamic_configs)
            .chain(&plan.layer_configs)
        {
            assert!(matches!(
                planned.hashed_djb2.value,
                InternedStringValue::Uninterned(_)
            ));
            assert!(matches!(
                planned.hashed_sha256.value,
                InternedStringValue::Uninterned(_)
            ));
        }

        for planned in &plan.cmab_configs {
            assert!(matches!(
                planned.name.value,
                InternedStringValue::Uninterned(_)
            ));
            assert!(matches!(
                planned.hashed_djb2.value,
                InternedStringValue::Uninterned(_)
            ));
            assert!(matches!(
                planned.hashed_sha256.value,
                InternedStringValue::Uninterned(_)
            ));
        }
    }
}