Skip to content
Open
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
1 change: 0 additions & 1 deletion Cargo.lock

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

1 change: 0 additions & 1 deletion crates/context_aware_config/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ actix-web = { workspace = true }
anyhow = { workspace = true }
bigdecimal = { workspace = true }
blake3 = { workspace = true }
cac_client = { path = "../cac_client" }
chrono = { workspace = true }
diesel = { workspace = true, features = ["numeric"] }
fred = { workspace = true, features = ["metrics"] }
Expand Down
61 changes: 21 additions & 40 deletions crates/context_aware_config/src/api/config/helpers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ use actix_web::{
HttpRequest,
web::{Data, Header, Json},
};
use cac_client::eval_cac;
use chrono::{DateTime, Utc};
use diesel::{ExpressionMethods, QueryDsl, RunQueryDsl, dsl::max};
use serde_json::{Map, Value};
Expand All @@ -16,9 +15,10 @@ use service_utils::{
},
service::types::{AppState, EncryptionKey, SchemaName, WorkspaceContext},
};
use superposition_core::{eval_config, eval_with_key_sources};
use superposition_macros::{bad_argument, db_error, unexpected_error};
use superposition_types::{
Condition, Config, ConfigFilter, DBConnection, PrefixList,
Config, ConfigFilter, Context, DBConnection, PrefixList,
api::config::{
ContextPayload, DetailedResolvedConfigValue, DetailedResolvedConfiguration,
ExplainKeyQuery, ExplainResolveQuery, Explanation, ExplanationTimelineItem,
Expand Down Expand Up @@ -295,7 +295,7 @@ fn evaluate_resolved_config(
merge_strategy: MergeStrategy,
_show_reason: bool, // TODO: Remove this
) -> Map<String, Value> {
eval_cac(config, query_data.into_inner(), merge_strategy)
eval_config(config, query_data.into_inner(), merge_strategy, None, None)
}

fn fetch_default_config_metadata(
Expand Down Expand Up @@ -338,33 +338,10 @@ fn fetch_default_config_metadata(
.collect())
}

/// Condition and id of the last matching context that set each key. A key absent from the
/// map was never overridden, so its value came from the default config.
fn resolve_key_sources(
config: &Config,
context_data: &Map<String, Value>,
) -> HashMap<String, (Condition, String)> {
let mut sources = HashMap::new();
for context in config.contexts.iter() {
if !superposition_types::apply(&context.condition, context_data) {
continue;
}
let override_id = context.override_with_keys.get_key();
let Some(overrides) = config.overrides.get(override_id) else {
continue;
};
let condition = context.condition.clone();
for key in overrides.keys() {
sources.insert(key.clone(), (condition.clone(), context.id.clone()));
}
}
sources
}

fn build_resolved_config(
resolved_config: Map<String, Value>,
metadata: &HashMap<String, DefaultConfigMetadata>,
sources: &HashMap<String, (Condition, String)>,
sources: &HashMap<String, &Context>,
) -> DetailedResolvedConfiguration {
resolved_config
.into_iter()
Expand All @@ -381,8 +358,8 @@ fn build_resolved_config(
description,
schema: schema.cloned(),
value,
context: source.map(|(condition, _)| condition.clone()),
context_id: source.map(|(_, id)| id.clone()),
context: source.map(|context| context.condition.clone()),
context_id: source.map(|context| context.id.clone()),
},
)
})
Expand Down Expand Up @@ -429,7 +406,6 @@ pub fn resolve_detailed(
master_encryption_key: &Option<EncryptionKey>,
) -> superposition::Result<DetailedResolvedConfiguration> {
let resolve_remote = resolve_options.resolve_remote;
let show_reason = resolve_options.show_reasoning.unwrap_or_default();
let resolution_config = apply_resolution_filters(config, resolve_options)?;

let context_data = prepare_remote_query_data(
Expand All @@ -442,17 +418,22 @@ pub fn resolve_detailed(
)?;
let merge_strategy = merge_strategy.into_inner();

let evaluated_context = evaluate_local_cohorts(
&resolution_config.dimensions,
context_data.clone().into_inner(),
);
let sources = resolve_key_sources(&resolution_config, &evaluated_context);

let resolved_config = evaluate_resolved_config(
resolution_config,
context_data,
// a single pass yields both the values and the context that set each key
let Config {
default_configs,
contexts,
overrides,
dimensions,
} = resolution_config;
let (resolved_config, sources) = eval_with_key_sources(
default_configs,
&contexts,
&overrides,
&dimensions,
context_data.into_inner(),
merge_strategy,
show_reason,
None,
None,
);
let keys = resolved_config.keys().cloned().collect::<Vec<_>>();
let metadata =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1031,10 +1031,14 @@ async fn get_applicable_variants_handler(
(dimension_params.into_inner(), identifier)
} else {
let req_body = req_body.map(|body| body.into_inner());
let (context, identifier) = req_body.map_or_else(
|| (QueryMap::default(), String::default()),
let (context, body_identifier) = req_body.map_or_else(
|| (QueryMap::default(), None),
|body| (body.context.into(), body.identifier),
);
// the SDK sends the identifier as a query param, older clients in the body
let identifier = body_identifier
.or(query_data.identifier)
.unwrap_or_default();
(context, identifier)
};

Expand Down
145 changes: 141 additions & 4 deletions crates/superposition_core/src/config.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
#[cfg(test)]
mod tests;

use std::{borrow::Cow, collections::HashMap};
use std::{
borrow::Cow,
collections::{HashMap, HashSet},
};

use serde_json::{json, Map, Value};
pub use superposition_types::api::config::MergeStrategy;
Expand Down Expand Up @@ -101,6 +104,93 @@ pub fn eval(
filter_prefixes: Option<Vec<String>>,
filter_exclude_prefixes: Option<Vec<String>>,
) -> Map<String, Value> {
eval_inner(
default_configs,
contexts,
overrides,
dimensions,
query_data,
merge_strategy,
filter_prefixes,
filter_exclude_prefixes,
false,
)
.0
}

/// Same as [`eval`], but also returns, for every resolved key that a context
/// overrode, the context that set it last and so decided its value. Keys left at
/// their default value have no entry.
///
/// Both come out of the same pass over the contexts, so this costs one evaluation.
#[allow(clippy::too_many_arguments)]
pub fn eval_with_key_sources<'a>(
default_configs: ExtendedMap,
contexts: &'a [Context],
overrides: &'a HashMap<String, Overrides>,
dimensions: &'a HashMap<String, DimensionInfo>,
query_data: Map<String, Value>,
merge_strategy: MergeStrategy,
filter_prefixes: Option<Vec<String>>,
filter_exclude_prefixes: Option<Vec<String>>,
) -> (Map<String, Value>, HashMap<String, &'a Context>) {
eval_inner(
default_configs,
contexts,
overrides,
dimensions,
query_data,
merge_strategy,
filter_prefixes,
filter_exclude_prefixes,
true,
)
}

/// Same as [`eval`], but also returns the variants that actually took effect.
///
/// `query_data["variantIds"]` lists every variant the caller is bucketed into, but a
/// variant whose context lost every key to a later context never reaches the resolved
/// config. Only the variants whose context is the source of at least one resolved key
/// (see [`eval_with_key_sources`]) are returned, in the order they appear in
/// `query_data["variantIds"]`.
#[allow(clippy::too_many_arguments)]
pub fn eval_with_applied_variants(
default_configs: ExtendedMap,
contexts: &[Context],
overrides: &HashMap<String, Overrides>,
dimensions: &HashMap<String, DimensionInfo>,
query_data: Map<String, Value>,
merge_strategy: MergeStrategy,
filter_prefixes: Option<Vec<String>>,
filter_exclude_prefixes: Option<Vec<String>>,
) -> (Map<String, Value>, Vec<String>) {
let variant_ids = query_data.get("variantIds").cloned();
let (config, key_sources) = eval_with_key_sources(
default_configs,
contexts,
overrides,
dimensions,
query_data,
merge_strategy,
filter_prefixes,
filter_exclude_prefixes,
);
(config, applied_variant_ids(variant_ids, &key_sources))
}

#[allow(clippy::too_many_arguments)]
fn eval_inner<'a>(
default_configs: ExtendedMap,
contexts: &'a [Context],
overrides: &'a HashMap<String, Overrides>,
dimensions: &'a HashMap<String, DimensionInfo>,
query_data: Map<String, Value>,
merge_strategy: MergeStrategy,
filter_prefixes: Option<Vec<String>>,
filter_exclude_prefixes: Option<Vec<String>>,
track_sources: bool,
) -> (Map<String, Value>, HashMap<String, &'a Context>) {
// Create Config struct to use existing filtering logic
let mut config = ConfigRef {
default_configs,
Expand All @@ -119,21 +209,50 @@ pub fn eval(

let modified_query_data = evaluate_local_cohorts(config.dimensions, query_data);

let overrides_map: Map<String, Value> = get_overrides(
let mut key_sources = HashMap::new();
let overrides_map: Map<String, Value> = merge_matching_overrides(
&modified_query_data,
config.contexts,
config.overrides,
&merge_strategy,
track_sources.then_some(&mut key_sources),
);

// overrides for keys missing from the default config are dropped while applying,
// so those keys have no source
key_sources.retain(|key, _| config.default_configs.contains_key(key));

// Apply overrides to default config
let result = apply_overrides_on_default_config(
config.default_configs,
overrides_map,
merge_strategy,
);

result.into_inner()
(result.into_inner(), key_sources)
}

/// The ids in `variant_ids` whose context is the source of at least one key.
fn applied_variant_ids(
variant_ids: Option<Value>,
key_sources: &HashMap<String, &Context>,
) -> Vec<String> {
let applied: HashSet<&str> = key_sources
.values()
.filter_map(|context| context.condition.get("variantIds"))
.filter_map(Value::as_str)
.collect();

match variant_ids {
Some(Value::Array(ids)) => ids
.into_iter()
.filter_map(|id| match id {
Value::String(id) if applied.contains(id.as_str()) => Some(id),
_ => None,
})
.collect(),
_ => Vec::new(),
}
}

// TODO: Move explain function to this file to avoid making this funcition public
Expand All @@ -157,9 +276,21 @@ pub fn merge(doc: &mut Value, patch: Value) {
/// contexts moves it out of `overrides` rather than cloning.
fn get_overrides(
query_data: &Map<String, Value>,
mut contexts: Vec<&Context>,
contexts: Vec<&Context>,
overrides: Cow<'_, HashMap<String, Overrides>>,
merge_strategy: &MergeStrategy,
) -> Map<String, Value> {
merge_matching_overrides(query_data, contexts, overrides, merge_strategy, None)
}

/// [`get_overrides`], optionally recording in `key_sources` the context that last
/// wrote each key.
fn merge_matching_overrides<'c>(
query_data: &Map<String, Value>,
mut contexts: Vec<&'c Context>,
mut overrides: Cow<'_, HashMap<String, Overrides>>,
merge_strategy: &MergeStrategy,
mut key_sources: Option<&mut HashMap<String, &'c Context>>,
) -> Map<String, Value> {
// borrow the keys instead of allocating two Strings per matching context
let mut final_consumer_context: HashMap<&str, &str> = HashMap::new();
Expand Down Expand Up @@ -192,6 +323,12 @@ fn get_overrides(
continue;
};

if let Some(sources) = key_sources.as_deref_mut() {
for key in overriden_value.keys() {
sources.insert(key.clone(), context);
}
}

match merge_strategy {
MergeStrategy::REPLACE => {
if let Some(doc) = required_overrides.as_object_mut() {
Expand Down
Loading
Loading