diff --git a/eppo_core/src/eval/eval_precomputed_assignments.rs b/eppo_core/src/eval/eval_precomputed_assignments.rs new file mode 100644 index 00000000..62926b97 --- /dev/null +++ b/eppo_core/src/eval/eval_precomputed_assignments.rs @@ -0,0 +1,167 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::eval::get_assignment; +use crate::ufc::{Assignment, AssignmentFormat, Environment, ValueWire, VariationType}; +use crate::{Attributes, Configuration, Str}; + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrecomputedConfiguration { + created_at: DateTime, + /// `format` is always `AssignmentFormat::Precomputed`. + format: AssignmentFormat, + // Environment might be missing if configuration was absent during evaluation. + environment: Option, + flags: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PrecomputedAssignment { + variation_type: VariationType, + variation_value: ValueWire, + + do_log: bool, + // If `do_log` is false, the client doesn’t need the field below. + #[serde(default, skip_serializing_if = "Option::is_none")] + allocation_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + variation_key: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + extra_logging: Option>, +} + +impl From for PrecomputedAssignment { + fn from(assignment: Assignment) -> PrecomputedAssignment { + match assignment.event { + Some(event) => PrecomputedAssignment { + variation_type: assignment.value.variation_type(), + variation_value: assignment.value.variation_value(), + do_log: true, + allocation_key: Some(event.base.allocation.clone()), + variation_key: Some(event.base.variation.clone()), + extra_logging: Some(event.base.extra_logging.clone()), + }, + None => PrecomputedAssignment { + variation_type: assignment.value.variation_type(), + variation_value: assignment.value.variation_value(), + do_log: false, + allocation_key: None, + variation_key: None, + extra_logging: None, + }, + } + } +} + +pub fn get_precomputed_assignments( + configuration: Option<&Configuration>, + subject_key: &Str, + subject_attributes: &Arc, + now: DateTime, +) -> PrecomputedConfiguration { + let Some(configuration) = configuration else { + log::warn!(target: "eppo", + subject_key; + "evaluating a flag before Eppo configuration has been fetched"); + return PrecomputedConfiguration { + created_at: now, + format: AssignmentFormat::Precomputed, + environment: None, + flags: HashMap::new(), + }; + }; + + let mut flags = HashMap::new(); + + for key in configuration.flags.compiled.flags.keys() { + match get_assignment( + Some(configuration), + key, + &subject_key, + &subject_attributes, + None, + now, + ) { + Ok(Some(assignment)) => { + flags.insert(key.clone(), assignment.into()); + } + Ok(None) => {} + Err(e) => { + eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); + } + } + } + + log::trace!(target: "eppo", + subject_key, + assignments:serde = flags; + "evaluated precomputed assignments"); + PrecomputedConfiguration { + created_at: now, + format: AssignmentFormat::Precomputed, + environment: Some(configuration.flags.compiled.environment.clone()), + flags, + } +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use crate::{ + eval::get_precomputed_assignments, ufc::UniversalFlagConfig, Attributes, Configuration, + SdkMetadata, + }; + use std::{fs, sync::Arc}; + + fn setup_test_config() -> Configuration { + let _ = env_logger::builder().is_test(true).try_init(); + + // Load test configuration + let ufc_config = UniversalFlagConfig::from_json( + SdkMetadata { + name: "test", + version: "0.1.0", + }, + fs::read("../sdk-test-data/ufc/flags-v1.json").unwrap(), + ) + .unwrap(); + Configuration::from_server_response(ufc_config, None) + } + + #[test] + fn test_precomputed_assignment_basic() { + let configuration = setup_test_config(); + + let subject_key = "test-subject-1".into(); + let subject_attributes = Arc::new(Attributes::new()); + let now = Utc::now(); + + // Get precomputed assignments + let precomputed = get_precomputed_assignments( + Some(&configuration), + &subject_key, + &subject_attributes, + now, + ); + + assert!( + !precomputed.flags.is_empty(), + "Should have precomputed flags" + ); + + // Each flag in the configuration should have an entry + for flag_key in precomputed.flags.keys() { + assert!( + precomputed.flags.contains_key(flag_key), + "Should have precomputed assignment for flag {}", + flag_key + ); + } + } +} diff --git a/eppo_core/src/eval/evaluator.rs b/eppo_core/src/eval/evaluator.rs index 431d3f86..27dfaf6a 100644 --- a/eppo_core/src/eval/evaluator.rs +++ b/eppo_core/src/eval/evaluator.rs @@ -4,6 +4,7 @@ use chrono::Utc; use crate::{ configuration_store::ConfigurationStore, + eval::eval_precomputed_assignments::PrecomputedConfiguration, events::AssignmentEvent, ufc::{Assignment, AssignmentValue, VariationType}, Attributes, Configuration, ContextAttributes, EvaluationError, SdkMetadata, Str, @@ -12,7 +13,7 @@ use crate::{ use super::{ eval_details::{EvaluationDetails, EvaluationResultWithDetails}, get_assignment, get_assignment_details, get_bandit_action, get_bandit_action_details, - BanditResult, + get_precomputed_assignments, BanditResult, }; pub struct EvaluatorConfig { @@ -112,6 +113,20 @@ impl Evaluator { ) } + pub fn get_precomputed_assignments( + &self, + subject_key: &Str, + subject_attributes: &Arc, + ) -> PrecomputedConfiguration { + let configuration = self.get_configuration(); + get_precomputed_assignments( + configuration.as_ref().map(AsRef::as_ref), + &subject_key, + &subject_attributes, + Utc::now(), + ) + } + fn get_configuration(&self) -> Option> { self.config.configuration_store.get_configuration() } diff --git a/eppo_core/src/eval/mod.rs b/eppo_core/src/eval/mod.rs index 7d59b864..8e54652a 100644 --- a/eppo_core/src/eval/mod.rs +++ b/eppo_core/src/eval/mod.rs @@ -1,6 +1,7 @@ mod eval_assignment; mod eval_bandits; mod eval_details_builder; +mod eval_precomputed_assignments; mod eval_rules; mod eval_visitor; mod evaluator; @@ -9,4 +10,5 @@ pub mod eval_details; pub use eval_assignment::{get_assignment, get_assignment_details}; pub use eval_bandits::{get_bandit_action, get_bandit_action_details, BanditResult}; +pub use eval_precomputed_assignments::{get_precomputed_assignments, PrecomputedConfiguration}; pub use evaluator::{Evaluator, EvaluatorConfig}; diff --git a/eppo_core/src/lib.rs b/eppo_core/src/lib.rs index a4bbf30d..fe1d770a 100644 --- a/eppo_core/src/lib.rs +++ b/eppo_core/src/lib.rs @@ -55,7 +55,6 @@ pub mod configuration_store; pub mod eval; pub mod events; pub mod poller_thread; -pub mod precomputed_assignments; #[cfg(feature = "pyo3")] pub mod pyo3; pub mod sharder; diff --git a/eppo_core/src/precomputed_assignments.rs b/eppo_core/src/precomputed_assignments.rs deleted file mode 100644 index d5e7f0e7..00000000 --- a/eppo_core/src/precomputed_assignments.rs +++ /dev/null @@ -1,86 +0,0 @@ -use crate::ufc::{Assignment, AssignmentFormat, Environment, VariationType}; -use crate::{Attributes, Configuration, Str}; -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::sync::Arc; - -// Request -#[derive(Debug, Deserialize)] -pub struct PrecomputedAssignmentsServiceRequestBody { - pub subject_key: Str, - pub subject_attributes: Arc, - // TODO: Add bandit actions - // #[serde(rename = "banditActions")] - // #[serde(skip_serializing_if = "Option::is_none")] - // bandit_actions: Option>, -} - -// Response -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct FlagAssignment { - pub allocation_key: Str, - pub variation_key: Str, - pub variation_type: VariationType, - pub variation_value: serde_json::Value, - /// Additional user-defined logging fields for capturing extra information related to the - /// assignment. - #[serde(flatten)] - pub extra_logging: HashMap, - pub do_log: bool, -} - -impl FlagAssignment { - pub fn try_from_assignment(assignment: Assignment) -> Option { - // WARNING! There is a problem here. The event is only populated for splits - // that have `do_log` set to true in the wire format. This means that - // all the ones present here are logged, but any splits that are not - // logged are not present here. - // - // This is a problem for us because we want to be able to return - // precomputed assignments for any split, logged or not, since we - // want to be able to return them for all flags. - // - // We need to fix this. - assignment.event.as_ref().map(|event| Self { - allocation_key: event.base.allocation.clone(), - variation_key: event.base.variation.clone(), - variation_type: assignment.value.variation_type(), - variation_value: assignment.value.variation_value(), - extra_logging: event - .base - .extra_logging - .iter() - .map(|(k, v)| (k.clone(), v.clone())) - .collect(), - do_log: true, - }) - } -} - -#[derive(Debug, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct PrecomputedAssignmentsServiceResponse { - created_at: chrono::DateTime, - format: AssignmentFormat, - environment: Environment, - flags: HashMap, -} - -impl PrecomputedAssignmentsServiceResponse { - pub fn from_configuration( - configuration: Arc, - flags: HashMap, - ) -> Self { - Self { - created_at: chrono::Utc::now(), - format: AssignmentFormat::Precomputed, - environment: { - Environment { - name: configuration.flags.compiled.environment.name.clone(), - } - }, - flags, - } - } -} diff --git a/eppo_core/src/ufc/assignment.rs b/eppo_core/src/ufc/assignment.rs index 65b76b7b..2fcfd677 100644 --- a/eppo_core/src/ufc/assignment.rs +++ b/eppo_core/src/ufc/assignment.rs @@ -6,6 +6,8 @@ use crate::{events::AssignmentEvent, Str}; use crate::ufc::VariationType; +use super::ValueWire; + /// Result of assignment evaluation. #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -17,7 +19,7 @@ pub struct Assignment { } /// Enum representing values assigned to a subject as a result of feature flag evaluation. -#[derive(Debug, Serialize, Deserialize, PartialEq, Clone)] +#[derive(Debug, Serialize, Deserialize, Clone)] #[serde(tag = "type", content = "value", rename_all = "SCREAMING_SNAKE_CASE")] pub enum AssignmentValue { /// A string value. @@ -29,10 +31,38 @@ pub enum AssignmentValue { /// A boolean value. Boolean(bool), /// Arbitrary JSON value. - Json(Arc), + Json { + raw: Str, + parsed: Arc, + }, +} + +impl PartialEq for AssignmentValue { + // Compare ignoring Json::raw. + fn eq(&self, other: &Self) -> bool { + match (self, other) { + (AssignmentValue::String(v1), AssignmentValue::String(v2)) => v1 == v2, + (AssignmentValue::Integer(v1), AssignmentValue::Integer(v2)) => v1 == v2, + (AssignmentValue::Numeric(v1), AssignmentValue::Numeric(v2)) => v1 == v2, + (AssignmentValue::Boolean(v1), AssignmentValue::Boolean(v2)) => v1 == v2, + ( + AssignmentValue::Json { parsed: v1, .. }, + AssignmentValue::Json { parsed: v2, .. }, + ) => v1 == v2, + _ => false, + } + } } impl AssignmentValue { + pub fn from_json(value: serde_json::Value) -> Result { + let raw = serde_json::to_string(&value)?; + Ok(AssignmentValue::Json { + raw: raw.into(), + parsed: Arc::new(value), + }) + } + /// Checks if the assignment value is of type String. /// /// # Returns @@ -189,7 +219,7 @@ impl AssignmentValue { /// # use eppo_core::ufc::AssignmentValue; /// use serde_json::json; /// - /// let value = AssignmentValue::Json(json!({ "key": "value" }).into()); + /// let value = AssignmentValue::from_json(json!({ "key": "value" }).into()).unwrap(); /// assert_eq!(value.is_json(), true); /// ``` pub fn is_json(&self) -> bool { @@ -205,12 +235,12 @@ impl AssignmentValue { /// # use eppo_core::ufc::AssignmentValue; /// use serde_json::json; /// - /// let value = AssignmentValue::Json(json!({ "key": "value" }).into()); + /// let value = AssignmentValue::from_json(json!({ "key": "value" }).into()).unwrap(); /// assert_eq!(value.as_json(), Some(&json!({ "key": "value" }))); /// ``` pub fn as_json(&self) -> Option<&serde_json::Value> { match self { - Self::Json(v) => Some(v), + Self::Json { raw: _, parsed } => Some(parsed), _ => None, } } @@ -224,12 +254,12 @@ impl AssignmentValue { /// # use eppo_core::ufc::AssignmentValue; /// use serde_json::json; /// - /// let value = AssignmentValue::Json(json!({ "key": "value" }).into()); + /// let value = AssignmentValue::from_json(json!({ "key": "value" }).into()).unwrap(); /// assert_eq!(value.to_json(), Some(json!({ "key": "value" }).into())); /// ``` pub fn to_json(self) -> Option> { match self { - Self::Json(v) => Some(v), + Self::Json { raw: _, parsed } => Some(parsed), _ => None, } } @@ -252,7 +282,7 @@ impl AssignmentValue { AssignmentValue::Integer(_) => VariationType::Integer, AssignmentValue::Numeric(_) => VariationType::Numeric, AssignmentValue::Boolean(_) => VariationType::Boolean, - AssignmentValue::Json(_) => VariationType::Json, + AssignmentValue::Json { .. } => VariationType::Json, } } @@ -260,23 +290,13 @@ impl AssignmentValue { /// /// # Returns /// - A JSON Value containing the variation value. - /// - /// # Examples - /// ``` - /// # use eppo_core::ufc::AssignmentValue; - /// # use serde_json::json; - /// let value = AssignmentValue::String("example".into()); - /// assert_eq!(value.variation_value(), json!("example")); - /// ``` - pub fn variation_value(&self) -> serde_json::Value { + pub(crate) fn variation_value(&self) -> ValueWire { match self { - AssignmentValue::String(s) => serde_json::Value::String(s.to_string()), - AssignmentValue::Integer(i) => serde_json::Value::Number((*i).into()), - AssignmentValue::Numeric(n) => serde_json::Value::Number( - serde_json::Number::from_f64(*n).unwrap_or_else(|| serde_json::Number::from(0)), - ), - AssignmentValue::Boolean(b) => serde_json::Value::Bool(*b), - AssignmentValue::Json(j) => j.as_ref().clone(), + AssignmentValue::String(s) => ValueWire::String(s.clone()), + AssignmentValue::Integer(i) => ValueWire::Number(*i as f64), + AssignmentValue::Numeric(n) => ValueWire::Number(*n), + AssignmentValue::Boolean(b) => ValueWire::Boolean(*b), + AssignmentValue::Json { raw, parsed: _ } => ValueWire::String(raw.clone()), } } } @@ -296,10 +316,12 @@ mod pyo3_impl { AssignmentValue::Integer(i) => i.to_object(py), AssignmentValue::Numeric(n) => n.to_object(py), AssignmentValue::Boolean(b) => b.to_object(py), - AssignmentValue::Json(j) => match serde_pyobject::to_pyobject(py, j) { - Ok(it) => it.unbind(), - Err(err) => return Err(err.0), - }, + AssignmentValue::Json { raw: _, parsed } => { + match serde_pyobject::to_pyobject(py, parsed) { + Ok(it) => it.unbind(), + Err(err) => return Err(err.0), + } + } }; Ok(obj) } diff --git a/eppo_core/src/ufc/models.rs b/eppo_core/src/ufc/models.rs index a0fbfd8e..052e158f 100644 --- a/eppo_core/src/ufc/models.rs +++ b/eppo_core/src/ufc/models.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, sync::Arc}; +use std::collections::HashMap; use derive_more::From; use regex::Regex; @@ -35,7 +35,7 @@ pub(crate) struct UniversalFlagConfigWire { #[derive(Debug, Serialize, Deserialize, Clone)] #[serde(rename_all = "UPPERCASE")] -pub(crate) enum AssignmentFormat { +pub enum AssignmentFormat { Client, Precomputed, Server, @@ -43,7 +43,7 @@ pub(crate) enum AssignmentFormat { #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] -pub(crate) struct Environment { +pub struct Environment { /// Name of the environment. pub name: Str, } @@ -130,7 +130,11 @@ impl ValueWire { VariationType::Integer => AssignmentValue::Integer(self.as_integer()?), VariationType::Numeric => AssignmentValue::Numeric(self.as_number()?), VariationType::Boolean => AssignmentValue::Boolean(self.as_boolean()?), - VariationType::Json => AssignmentValue::Json(Arc::new(self.into_json()?)), + VariationType::Json => { + let raw = self.into_string()?; + let parsed = serde_json::from_str(&raw).ok()?; + AssignmentValue::Json { raw, parsed } + } }) } @@ -164,11 +168,6 @@ impl ValueWire { _ => None, } } - - fn into_json(self) -> Option { - let s = self.into_string()?; - serde_json::from_str(&s).ok()? - } } #[derive(Debug, Serialize, Deserialize, Clone)] diff --git a/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/handler_assignments.rs similarity index 72% rename from fastly-edge-assignments/src/handlers/assignments.rs rename to fastly-edge-assignments/src/handlers/handler_assignments.rs index fe3ae064..6e227ba3 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/handler_assignments.rs @@ -1,18 +1,15 @@ -use eppo_core::configuration_store::ConfigurationStore; -use eppo_core::eval::{Evaluator, EvaluatorConfig}; -use eppo_core::precomputed_assignments::{ - FlagAssignment, PrecomputedAssignmentsServiceRequestBody, PrecomputedAssignmentsServiceResponse, -}; +use chrono::Utc; use eppo_core::ufc::UniversalFlagConfig; -use eppo_core::{Attributes, Configuration, SdkMetadata}; +use eppo_core::{Attributes, Configuration, SdkMetadata, Str}; use fastly::http::StatusCode; use fastly::kv_store::KVStoreError; use fastly::{Error, KVStore, Request, Response}; +use serde::Deserialize; use sha2::{Digest, Sha256}; -use std::collections::HashMap; use std::sync::Arc; const KV_STORE_NAME: &str = "edge-assignment-kv-store"; +// TODO: Migrate authorization to header `Authorization`. const SDK_KEY_QUERY_PARAM: &str = "apiKey"; // For legacy reasons this is named `apiKey` const SDK_NAME: &str = "fastly-edge-assignments"; @@ -28,6 +25,17 @@ fn token_hash(sdk_key: &str) -> String { base64_url::encode(&hasher.finalize()) } +// Request +#[derive(Debug, Deserialize)] +struct PrecomputedAssignmentsServiceRequestBody { + pub subject_key: Str, + pub subject_attributes: Arc, + // TODO: Add bandit actions + // #[serde(rename = "banditActions")] + // #[serde(skip_serializing_if = "Option::is_none")] + // bandit_actions: Option>, +} + pub fn handle_assignments(mut req: Request) -> Result { // Extract the SDK key and generate a token hash matching the pre-defined encoding. let Some(token) = req @@ -112,50 +120,21 @@ pub fn handle_assignments(mut req: Request) -> Result { }; let configuration = Configuration::from_server_response(ufc_config, None); - let configuration = Arc::new(configuration); - let flag_keys = configuration.flag_keys(); - let configuration_store = ConfigurationStore::new(); - configuration_store.set_configuration(configuration.clone()); - let evaluator = Evaluator::new(EvaluatorConfig { - configuration_store: Arc::new(configuration_store), - sdk_metadata: SdkMetadata { - name: SDK_NAME, - version: SDK_VERSION, - }, - }); - - let subject_assignments = flag_keys - .iter() - .filter_map(|key| { - match evaluator.get_assignment(key, &subject_key, &subject_attributes, None) { - Ok(Some(assignment)) => FlagAssignment::try_from_assignment(assignment) - .map(|flag_assignment| (key.clone(), flag_assignment)), - Ok(None) => None, - Err(e) => { - eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); - None - } - } - }) - .collect::>(); - - // Create the response - let assignments_response = PrecomputedAssignmentsServiceResponse::from_configuration( - configuration, - subject_assignments, + let precomputed_configuration = eppo_core::eval::get_precomputed_assignments( + Some(&configuration), + &subject_key, + &subject_attributes, + Utc::now(), ); // Create an HTTP OK response with the assignments - let response = match Response::from_status(StatusCode::OK).with_body_json(&assignments_response) - { - Ok(response) => response, - Err(e) => { + Response::from_status(StatusCode::OK) + .with_body_json(&precomputed_configuration) + .or_else(|e| { eprintln!("Failed to serialize response: {:?}", e); - return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Failed to serialize response")); - } - }; - Ok(response) + Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) + .with_body_text_plain("Failed to serialize response")) + }) } #[cfg(test)] diff --git a/fastly-edge-assignments/src/handlers/mod.rs b/fastly-edge-assignments/src/handlers/mod.rs index a6b6ae69..acc978aa 100644 --- a/fastly-edge-assignments/src/handlers/mod.rs +++ b/fastly-edge-assignments/src/handlers/mod.rs @@ -1,7 +1,7 @@ // Declare submodules -pub mod assignments; +pub mod handler_assignments; pub mod health; // Re-export items to make them more convenient to use -pub use assignments::handle_assignments; +pub use handler_assignments::handle_assignments; pub use health::handle_health;