Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor(core): move PrecomputedConfiguration into core #110

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
182 changes: 86 additions & 96 deletions eppo_core/src/eval/eval_precomputed_assignments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,29 +2,58 @@ 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;
use crate::ufc::{AssignmentFormat, Environment};
use crate::{Attributes, Configuration, EvaluationError, Str};
use crate::ufc::{Assignment, AssignmentFormat, Environment, ValueWire, VariationType};
use crate::{Attributes, Configuration, Str};

#[derive(Debug)]
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct PrecomputedConfiguration {
pub created_at: DateTime<Utc>,
pub format: AssignmentFormat,
pub environment: Environment,
pub flags: HashMap<String, Result<Assignment, EvaluationError>>,
created_at: DateTime<Utc>,
Copy link
Collaborator Author

@rasendubi rasendubi Dec 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another change here is that all fields of PrecomputedConfiguration are private to eppo_core: edge server shouldn't need to tinker with them and clients would have their own parsers.

Basically, the only way to interact with PrecomputedConfiguration is via serialize/deserialize.

This should allow us tweaking the type here without being afraid of breaking the edge server.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea! That's a nice way to signal where it should used, noted.

/// `format` is always `AssignmentFormat::Precomputed`.
format: AssignmentFormat,
// Environment might be missing if configuration was absent during evaluation.
environment: Option<Environment>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There's no good default environment value, so decided to null it instead of sending random string.

flags: HashMap<String, PrecomputedAssignment>,
}

impl PrecomputedConfiguration {
pub fn empty(environment_name: &str) -> Self {
Self {
created_at: Utc::now(),
format: AssignmentFormat::Precomputed,
environment: Environment {
name: environment_name.into(),
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
struct PrecomputedAssignment {
variation_type: VariationType,
variation_value: ValueWire,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One diff here is that JSON assignment are now encoded as strings — following UFC flags config approach


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<Str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
variation_key: Option<Str>,
#[serde(default, skip_serializing_if = "Option::is_none")]
extra_logging: Option<HashMap<String, String>>,
Comment on lines +30 to +35
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Decided to skip serializing these if do_log is false for now. There is no reason for the client to need these except the logging purposes

}

impl From<Assignment> 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,
},
flags: HashMap::new(),
}
}
}
Expand All @@ -33,53 +62,50 @@ pub fn get_precomputed_assignments(
configuration: Option<&Configuration>,
subject_key: &Str,
subject_attributes: &Arc<Attributes>,
early_exit: bool,
now: DateTime<Utc>,
) -> PrecomputedConfiguration {
if let Some(config) = configuration {
let mut flags = HashMap::new();

for key in config.flags.compiled.flags.keys() {
match get_assignment(
Some(config),
key,
&subject_key,
&subject_attributes,
None,
now,
) {
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;
}
}
}
}

log::trace!(target: "eppo",
subject_key,
assignments:serde = flags;
"evaluated precomputed assignments");
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: {
Environment {
name: config.flags.compiled.environment.name.clone(),
}
},
flags,
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);
}
}
} else {
log::warn!(target: "eppo",
subject_key;
"evaluating a flag before Eppo configuration has been fetched");
PrecomputedConfiguration::empty("unknown")
}

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,
}
}

Expand All @@ -88,9 +114,8 @@ mod tests {
use chrono::Utc;

use crate::{
eval::get_precomputed_assignments,
ufc::{UniversalFlagConfig, VariationType},
Attributes, Configuration, SdkMetadata,
eval::get_precomputed_assignments, ufc::UniversalFlagConfig, Attributes, Configuration,
SdkMetadata,
};
use std::{fs, sync::Arc};

Expand Down Expand Up @@ -122,7 +147,6 @@ mod tests {
Some(&configuration),
&subject_key,
&subject_attributes,
false,
now,
);

Expand All @@ -140,38 +164,4 @@ mod tests {
);
}
}

#[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 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 = get_precomputed_assignments(
Some(&configuration),
&subject_key,
&subject_attributes,
true,
now,
);

// 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"
);
}
}
2 changes: 0 additions & 2 deletions eppo_core/src/eval/evaluator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -117,14 +117,12 @@ impl Evaluator {
&self,
subject_key: &Str,
subject_attributes: &Arc<Attributes>,
early_exit: bool,
) -> PrecomputedConfiguration {
let configuration = self.get_configuration();
get_precomputed_assignments(
configuration.as_ref().map(AsRef::as_ref),
&subject_key,
&subject_attributes,
early_exit,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 ok your philosophy is not to implement something without a use, I can understand that

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wouldn't say it's my "philosophy" but in this case I'm pretty skeptical that we ever see a use case for early exit (given we spec'ed precomputed format and that it shouldn't early exit).

Utc::now(),
)
}
Expand Down
78 changes: 50 additions & 28 deletions eppo_core/src/ufc/assignment.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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")]
Expand All @@ -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.
Expand All @@ -29,10 +31,38 @@ pub enum AssignmentValue {
/// A boolean value.
Boolean(bool),
/// Arbitrary JSON value.
Json(Arc<serde_json::Value>),
Json {
raw: Str,
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Saving raw json value here. This will also allow us to return raw string in some SDKs if users want to use their own parser.

parsed: Arc<serde_json::Value>,
},
}

impl PartialEq for AssignmentValue {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cool!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

haha, PartialEq was auto-generated before but with adding raw json strings, we can't compare on them (because serialization may differ), so I had to roll my own implementation — that's rather un-cool 😅

// 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<AssignmentValue, serde_json::Error> {
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
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
}
}
Expand All @@ -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<Arc<serde_json::Value>> {
match self {
Self::Json(v) => Some(v),
Self::Json { raw: _, parsed } => Some(parsed),
_ => None,
}
}
Expand All @@ -252,31 +282,21 @@ impl AssignmentValue {
AssignmentValue::Integer(_) => VariationType::Integer,
AssignmentValue::Numeric(_) => VariationType::Numeric,
AssignmentValue::Boolean(_) => VariationType::Boolean,
AssignmentValue::Json(_) => VariationType::Json,
AssignmentValue::Json { .. } => VariationType::Json,
}
}

/// Returns the raw value of the variation.
///
/// # 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()),
}
}
}
Expand All @@ -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)
}
Expand Down
Loading
Loading