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
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,7 @@ async fn main() {
let provider = LocalResolutionProvider::new(
Box::new(file_source),
None,
RefreshStrategy::OnDemand(OnDemandStrategy {
ttl: 60,
..Default::default()
}),
RefreshStrategy::OnDemand(OnDemandStrategy::new(60_000, None, None)),
);
provider.init(EvaluationContext::default()).await.unwrap();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,7 @@ async fn main() {
let provider = LocalResolutionProvider::new(
Box::new(http_source),
None,
RefreshStrategy::Polling(PollingStrategy {
interval: 30,
timeout: Some(10),
}),
RefreshStrategy::Polling(PollingStrategy::new(30_000, Some(10_000))),
);
provider.init(EvaluationContext::default()).await.unwrap();

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,7 @@ async fn main() {
let provider = LocalResolutionProvider::new(
Box::new(http_source),
Some(Box::new(file_source)),
RefreshStrategy::Polling(PollingStrategy {
interval: 10,
timeout: Some(10),
}),
RefreshStrategy::Polling(PollingStrategy::new(10_000, Some(10_000))),
);

// Register with OpenFeature and create a client
Expand Down
11 changes: 5 additions & 6 deletions crates/superposition_provider/examples/polling_example.rs
Original file line number Diff line number Diff line change
Expand Up @@ -38,14 +38,16 @@ async fn main() {
let token = env_or("SUPERPOSITION_TOKEN", "token");
let org_id = env_or("SUPERPOSITION_ORG_ID", "localorg");
let workspace = env_or("SUPERPOSITION_WORKSPACE", "dev");
let poll_interval: u64 = env_or("POLL_INTERVAL", "10").parse().unwrap_or(10);
let poll_interval_ms: u64 = env_or("POLL_INTERVAL_MS", "10000")
.parse()
.unwrap_or(10_000);
let print_interval: u64 = env_or("PRINT_INTERVAL", "5").parse().unwrap_or(5);
let config_key = env_or("CONFIG_KEY", "max_connections");

println!("=== Superposition Polling Example ===");
println!("Endpoint: {}", endpoint);
println!("Org / Workspace: {} / {}", org_id, workspace);
println!("Poll interval: {}s", poll_interval);
println!("Poll interval: {}ms", poll_interval_ms);
println!("Print interval: {}s", print_interval);
println!("Watching key: {}", config_key);
println!();
Expand All @@ -57,10 +59,7 @@ async fn main() {
let provider = LocalResolutionProvider::new(
Box::new(http_source),
None,
RefreshStrategy::Polling(PollingStrategy {
interval: poll_interval,
timeout: Some(10),
}),
RefreshStrategy::Polling(PollingStrategy::new(poll_interval_ms, Some(10_000))),
);

// Register with OpenFeature and create a client
Expand Down
42 changes: 21 additions & 21 deletions crates/superposition_provider/src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,21 +78,21 @@ impl CacConfig {
match &self.options.refresh_strategy {
RefreshStrategy::Polling(polling_strategy) => {
info!(
"Using PollingStrategy: interval={}s, timeout={}s",
polling_strategy.interval,
polling_strategy.timeout.unwrap_or(30)
"Using PollingStrategy: interval={}ms, timeout={}ms",
polling_strategy.interval_ms(),
polling_strategy.timeout_ms().unwrap_or(30_000)
);
let task_token = self.start_polling(polling_strategy.interval).await;
let task_token = self.start_polling(polling_strategy.interval_ms()).await;
let mut polling_task_cancellation_token =
self.polling_task_cancellation_token.write().await;
*polling_task_cancellation_token = Some(task_token);
}
RefreshStrategy::OnDemand(on_demand_strategy) => {
info!(
"Using OnDemandStrategy: ttl={}s, use_stale_on_error={}, timeout={}s",
on_demand_strategy.ttl,
on_demand_strategy.use_stale_on_error.unwrap_or(false),
on_demand_strategy.timeout.unwrap_or(30)
"Using OnDemandStrategy: ttl={}ms, use_stale_on_error={}, timeout={}ms",
on_demand_strategy.ttl_ms(),
on_demand_strategy.use_stale_on_error(),
on_demand_strategy.timeout_ms().unwrap_or(30_000)
);
}
RefreshStrategy::Watch(_) => {
Expand All @@ -106,7 +106,7 @@ impl CacConfig {
Ok(())
}

async fn start_polling(&self, interval: u64) -> CancellationToken {
async fn start_polling(&self, interval_ms: u64) -> CancellationToken {
let superposition_options = self.superposition_options.clone();
let cached_config = self.cached_config.clone();
let last_updated = self.last_updated.clone();
Expand All @@ -132,7 +132,7 @@ impl CacConfig {
error!("Polling error: {}", e);
}
}
sleep(Duration::from_secs(interval)).await;
sleep(Duration::from_millis(interval_ms)).await;
}
} => {}
}
Expand All @@ -141,14 +141,14 @@ impl CacConfig {
cancellation_token
}

pub async fn on_demand_config(&self, ttl: u64, use_stale: bool) -> Result<Config> {
pub async fn on_demand_config(&self, ttl_ms: u64, use_stale: bool) -> Result<Config> {
let now = chrono::Utc::now();
let last_updated;
{
last_updated = self.last_updated.read().await;
}
let should_refresh = match *last_updated {
Some(last) => (now - last).num_seconds() > ttl as i64,
Some(last) => (now - last).num_milliseconds() > ttl_ms as i64,
None => true,
};
Comment on lines 150 to 153

Expand Down Expand Up @@ -357,18 +357,18 @@ impl ExperimentationConfig {
match &self.options.refresh_strategy {
RefreshStrategy::Polling(polling_strategy) => {
info!(
"Using PollingStrategy for experiments: interval={}s",
polling_strategy.interval
"Using PollingStrategy for experiments: interval={}ms",
polling_strategy.interval_ms()
);
let task_token = self.start_polling(polling_strategy.interval).await;
let task_token = self.start_polling(polling_strategy.interval_ms()).await;
let mut polling_task_cancellation_token =
self.polling_task_cancellation_token.write().await;
*polling_task_cancellation_token = Some(task_token);
}
RefreshStrategy::OnDemand(on_demand_strategy) => {
info!(
"Using OnDemandStrategy for experiments: ttl={}s",
on_demand_strategy.ttl
"Using OnDemandStrategy for experiments: ttl={}ms",
on_demand_strategy.ttl_ms()
);
}
RefreshStrategy::Watch(_) => {
Expand All @@ -382,7 +382,7 @@ impl ExperimentationConfig {
Ok(())
}

async fn start_polling(&self, interval: u64) -> CancellationToken {
async fn start_polling(&self, interval_ms: u64) -> CancellationToken {
let superposition_options = self.superposition_options.clone();
let cached_experiments = self.cached_experiments.clone();
let cached_experiment_groups = self.cached_experiment_groups.clone();
Expand Down Expand Up @@ -421,7 +421,7 @@ impl ExperimentationConfig {
}
_ => {}
}
sleep(Duration::from_secs(interval)).await;
sleep(Duration::from_millis(interval_ms)).await;
}
} => {}
}
Expand All @@ -432,14 +432,14 @@ impl ExperimentationConfig {

pub async fn on_demand_config(
&self,
ttl: u64,
ttl_ms: u64,
use_stale: bool,
) -> Result<Experiments> {
let now = chrono::Utc::now();
let last_updated = self.last_updated.read().await;

let should_refresh = match *last_updated {
Some(last) => (now - last).num_seconds() > ttl as i64,
Some(last) => (now - last).num_milliseconds() > ttl_ms as i64,
None => true,
};
Comment on lines 441 to 444

Expand Down
122 changes: 93 additions & 29 deletions crates/superposition_provider/src/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -192,33 +192,45 @@ pub fn evaluation_context_to_map(context: EvaluationContext) -> Map<String, Valu
dimension_data
}

/// Convert serde_json Value to OpenFeature StructValue
/// Convert serde_json Value to OpenFeature StructValue.
///
/// Only JSON objects convert. A top-level array cannot: `resolve_struct` must return a
/// `StructValue`, which has no array form. Callers get a TYPE_MISMATCH rather than the
/// index-keyed map (`{"0": .., "1": ..}`) this used to fabricate, which was silently not
/// the value the flag held. The Java and Python clients *can* return a top-level array,
/// because their SDKs' object type admits one; this is an upstream limitation of
/// `open_feature`, not a deliberate difference.
pub fn value_to_struct(value: Value) -> Result<open_feature::StructValue> {
match value {
Value::Object(map) => {
let mut fields = HashMap::new();
for (k, v) in map {
let open_feature_value = value_to_openfeature_value(v)?;
fields.insert(k, open_feature_value);
}
// StructValue is just a struct with a fields HashMap, not a complex conversion
Ok(open_feature::StructValue { fields })
}
Value::Array(list) => {
let mut fields = HashMap::new();
for (index, item) in list.into_iter().enumerate() {
let open_feature_value = value_to_openfeature_value(item)?;
fields.insert(index.to_string(), open_feature_value);
}
Ok(open_feature::StructValue { fields })
}
Value::Object(map) => Ok(open_feature::StructValue {
fields: object_fields(map)?,
}),
_ => Err(SuperpositionError::ConfigError(format!(
"Cannot convert {:?} to StructValue - flag must be an object/array",
"Cannot convert {:?} to StructValue - flag must be an object",
value
))),
}
}

/// Convert a JSON object's entries to OpenFeature fields, dropping null-valued keys.
///
/// `open_feature::Value` has no null variant. Dropping the key leaves the rest of the
/// object readable; erroring would make one null field render the entire flag
/// unresolvable, which is what this used to do.
fn object_fields(map: Map<String, Value>) -> Result<HashMap<String, open_feature::Value>> {
let mut fields = HashMap::new();
for (k, v) in map {
if v.is_null() {
log::debug!(
"Dropping null-valued field '{k}': OpenFeature has no null representation"
);
continue;
}
fields.insert(k, value_to_openfeature_value(v)?);
}
Ok(fields)
}

/// Convert serde_json Value to OpenFeature Value
pub fn value_to_openfeature_value(value: Value) -> Result<open_feature::Value> {
match value {
Expand All @@ -241,18 +253,70 @@ pub fn value_to_openfeature_value(value: Value) -> Result<open_feature::Value> {
.map(value_to_openfeature_value)
.collect::<Result<Vec<_>>>()?,
)),
Value::Object(map) => {
let fields = map
.into_iter()
.map(|(k, v)| Ok((k, value_to_openfeature_value(v)?)))
.collect::<Result<HashMap<String, open_feature::Value>>>()?;

// Create StructValue directly with fields HashMap
let struct_value = open_feature::StructValue { fields };
Ok(open_feature::Value::Struct(struct_value))
}
Value::Object(map) => Ok(open_feature::Value::Struct(open_feature::StructValue {
fields: object_fields(map)?,
})),
// Reachable only for a null *array element*: object fields are filtered out by
// `object_fields` first. Dropping it here would shift every later index, so this
// stays an error.
Value::Null => Err(SuperpositionError::ConfigError(
"Cannot convert null to OpenFeature value".to_string(),
)),
}
}

#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;

#[test]
fn an_object_flag_keeps_its_field_types() {
let fields = value_to_struct(json!({"tier": "gold", "credits": 5, "ratio": 1.5}))
.expect("an object converts")
.fields;

assert_eq!(fields["tier"], open_feature::Value::String("gold".into()));
assert_eq!(fields["credits"], open_feature::Value::Int(5));
assert_eq!(fields["ratio"], open_feature::Value::Float(1.5));
}

#[test]
fn a_null_field_is_dropped_rather_than_failing_the_whole_flag() {
let fields = value_to_struct(json!({"tier": "gold", "promo": null}))
.expect("a null field does not fail the flag")
.fields;

assert_eq!(fields["tier"], open_feature::Value::String("gold".into()));
assert!(!fields.contains_key("promo"));
}

#[test]
fn a_top_level_array_is_a_type_mismatch_not_an_index_keyed_map() {
// This used to succeed, fabricating StructValue { "0": .., "1": .. } — a value the
// flag never held. Java and Python return the array itself; `StructValue` cannot.
assert!(value_to_struct(json!([1, 2, 3])).is_err());
}

#[test]
fn an_array_nested_in_an_object_needs_no_special_handling() {
let fields = value_to_struct(json!({"tags": ["a", "b"]}))
.expect("a nested array converts")
.fields;

assert_eq!(
fields["tags"],
open_feature::Value::Array(vec![
open_feature::Value::String("a".into()),
open_feature::Value::String("b".into()),
])
);
}

#[test]
fn a_primitive_is_not_an_object() {
assert!(value_to_struct(json!("Rupee")).is_err());
assert!(value_to_struct(json!(true)).is_err());
assert!(value_to_struct(json!(10)).is_err());
}
}
Loading
Loading