diff --git a/eppo_core/src/error.rs b/eppo_core/src/error.rs index 4558f795..4124dc71 100644 --- a/eppo_core/src/error.rs +++ b/eppo_core/src/error.rs @@ -84,7 +84,7 @@ pub enum EvaluationError { /// default assignment. #[derive(thiserror::Error, Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[serde(rename_all = "SCREAMING_SNAKE_CASE")] -pub(crate) enum EvaluationFailure { +pub enum EvaluationFailure { /// True evaluation error that should be returned to the user. #[error(transparent)] Error(EvaluationError), diff --git a/eppo_core/src/eval/eval_precomputed_assignment.rs b/eppo_core/src/eval/eval_precomputed_assignment.rs new file mode 100644 index 00000000..f10a0df5 --- /dev/null +++ b/eppo_core/src/eval/eval_precomputed_assignment.rs @@ -0,0 +1,114 @@ +use std::collections::HashMap; + +use crate::{error::EvaluationFailure, ufc::Assignment}; + +#[derive(Debug)] +pub struct PrecomputedConfiguration { + pub flags: HashMap>, +} + +#[cfg(test)] +mod tests { + use chrono::Utc; + + use crate::{ + configuration_store::ConfigurationStore, + eval::{Evaluator, EvaluatorConfig}, + ufc::{UniversalFlagConfig, VariationType}, + 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 configuration_store = Arc::new(ConfigurationStore::new()); + configuration_store.set_configuration(Arc::new(configuration)); + + let evaluator = Evaluator::new(EvaluatorConfig { + configuration_store: configuration_store.clone(), + sdk_metadata: SdkMetadata { + name: "test", + version: "0.1.0", + }, + }); + + let subject_key = "test-subject-1".into(); + let subject_attributes = Arc::new(Attributes::new()); + let now = Utc::now(); + + // Get precomputed assignments + let precomputed = + evaluator.get_precomputed_assignment(&subject_key, &subject_attributes, false); + + 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 + ); + } + } + + #[test] + fn test_precomputed_assignment_early_exit() { + let mut configuration = setup_test_config(); + let num_good_flags = configuration.flags.compiled.flags.len(); + + // Add a flag that will cause an evaluation error + configuration.flags.compiled.flags.insert( + "error-flag".to_string(), + Ok(crate::ufc::Flag { + variation_type: VariationType::String, + allocations: vec![].into_boxed_slice(), + }), + ); + + let configuration_store = Arc::new(ConfigurationStore::new()); + configuration_store.set_configuration(Arc::new(configuration)); + + let evaluator = Evaluator::new(EvaluatorConfig { + configuration_store: configuration_store.clone(), + sdk_metadata: SdkMetadata { + name: "test", + version: "0.1.0", + }, + }); + + let subject_key = "test-subject-1".into(); + let subject_attributes = Arc::new(Attributes::new()); + let now = Utc::now(); + + // Get assignments with early exit + let precomputed_with_early_exit = + evaluator.get_precomputed_assignment(&subject_key, &subject_attributes, true); + + // Verify we have fewer entries due to early exit + assert!( + precomputed_with_early_exit.flags.len() < num_good_flags, + "Early exit should stop processing on first error" + ); + } +} diff --git a/eppo_core/src/eval/evaluator.rs b/eppo_core/src/eval/evaluator.rs index 431d3f86..d4489c6c 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_assignment::PrecomputedConfiguration, events::AssignmentEvent, ufc::{Assignment, AssignmentValue, VariationType}, Attributes, Configuration, ContextAttributes, EvaluationError, SdkMetadata, Str, @@ -112,6 +113,36 @@ impl Evaluator { ) } + pub fn get_precomputed_assignment( + &self, + subject_key: &Str, + subject_attributes: &Arc, + early_exit: bool, + ) -> PrecomputedConfiguration { + let config = self.get_configuration(); + + let mut flags = HashMap::new(); + + if let Some(config) = config { + for key in config.flags.compiled.flags.keys() { + match self.get_assignment(key, &subject_key, &subject_attributes, None) { + Ok(Some(assignment)) => { + flags.insert(key.clone(), Ok(assignment)); + } + Ok(None) => continue, + Err(e) => { + eprintln!("Failed to evaluate assignment for key {}: {:?}", key, e); + if early_exit { + break; + } + } + } + } + } + + PrecomputedConfiguration { flags } + } + 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..973d4641 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_assignment; mod eval_rules; mod eval_visitor; mod evaluator; 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/fastly-edge-assignments/src/handlers/assignments.rs b/fastly-edge-assignments/src/handlers/handler_assignments.rs similarity index 60% rename from fastly-edge-assignments/src/handlers/assignments.rs rename to fastly-edge-assignments/src/handlers/handler_assignments.rs index fe3ae064..41ded6ff 100644 --- a/fastly-edge-assignments/src/handlers/assignments.rs +++ b/fastly-edge-assignments/src/handlers/handler_assignments.rs @@ -1,13 +1,11 @@ use eppo_core::configuration_store::ConfigurationStore; use eppo_core::eval::{Evaluator, EvaluatorConfig}; -use eppo_core::precomputed_assignments::{ - FlagAssignment, PrecomputedAssignmentsServiceRequestBody, PrecomputedAssignmentsServiceResponse, -}; -use eppo_core::ufc::UniversalFlagConfig; -use eppo_core::{Attributes, Configuration, SdkMetadata}; +use eppo_core::ufc::{Assignment, UniversalFlagConfig, VariationType}; +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, Serialize}; use sha2::{Digest, Sha256}; use std::collections::HashMap; use std::sync::Arc; @@ -28,6 +26,60 @@ 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>, +} + +// 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.clone(), + do_log: true, + }) + } +} + +#[derive(Debug)] +struct PrecomputedAssignmentsResponse { + flags: HashMap, +} + 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 @@ -113,48 +165,40 @@ 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 assignments_response = PrecomputedAssignmentsResponse { + flags: Evaluator::new(EvaluatorConfig { + configuration_store: Arc::new(configuration_store), + sdk_metadata: SdkMetadata { + name: SDK_NAME, + version: SDK_VERSION, + }, + }) + .get_precomputed_assignment(&subject_key, &subject_attributes, false) + .flags + .into_iter() + .map(|(k, v)| { + v.ok() + .and_then(|assignment| FlagAssignment::try_from_assignment(assignment)) + .map(|flag_assignment| (k, flag_assignment)) + }) + .flatten() + .collect(), + }; // 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) => { - eprintln!("Failed to serialize response: {:?}", e); - return Ok(Response::from_status(StatusCode::INTERNAL_SERVER_ERROR) - .with_body_text_plain("Failed to serialize response")); - } - }; + let response = + match Response::from_status(StatusCode::OK).with_body_json(&assignments_response.flags) { + Ok(response) => response, + Err(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) } 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; diff --git a/sdk-test-data b/sdk-test-data index 7db46318..825452d4 160000 --- a/sdk-test-data +++ b/sdk-test-data @@ -1 +1 @@ -Subproject commit 7db46318cf74a3286a06afc448358cd379ae0cb9 +Subproject commit 825452d4e967c2996c549c21fc3848374149ccba