Skip to content

Commit 4188854

Browse files
aepfliclaude
andcommitted
perf(python): add pre-eval cache and context key filtering to PyO3 bindings
Add host-side optimizations to the PyO3 FlagEvaluator matching the WASM and Go implementations: - Pre-evaluated cache for static/disabled flags (skip Rust evaluation) - Context key filtering: extract only required keys from PyDict before converting to serde_json::Value, avoiding full dict serialization - evaluate_flag_pre_enriched path for pre-enriched filtered contexts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 8d7d51a commit 4188854

1 file changed

Lines changed: 169 additions & 18 deletions

File tree

python/src/lib.rs

Lines changed: 169 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
use ::flagd_evaluator::ValidationMode;
1+
use ::flagd_evaluator::{EvaluationResult, ValidationMode};
22
use pyo3::prelude::*;
33
use pyo3::types::PyDict;
4-
use serde_json::Value;
4+
use serde_json::{Map, Value};
5+
use std::collections::{HashMap, HashSet};
56

67
/// FlagEvaluator - Stateful feature flag evaluator
78
///
@@ -26,6 +27,84 @@ use serde_json::Value;
2627
struct FlagEvaluator {
2728
/// Wrap the Rust FlagEvaluator directly - no duplication!
2829
inner: ::flagd_evaluator::FlagEvaluator,
30+
/// Cache of pre-evaluated results for static and disabled flags
31+
pre_evaluated: HashMap<String, EvaluationResult>,
32+
/// Required context keys per flag for context filtering
33+
required_context_keys: HashMap<String, HashSet<String>>,
34+
}
35+
36+
/// Build a filtered, pre-enriched context containing only the required keys.
37+
///
38+
/// Extracts only the keys in `required_keys` from the PyDict, adds `targetingKey`
39+
/// (defaulting to `""`) and `$flagd` enrichment with `flagKey` and `timestamp`.
40+
fn build_filtered_context(
41+
flag_key: &str,
42+
context: &Bound<'_, PyDict>,
43+
required_keys: &HashSet<String>,
44+
) -> PyResult<Value> {
45+
let mut map = Map::new();
46+
47+
// Extract only required keys from PyDict
48+
for key in required_keys {
49+
if let Some(py_val) = context.get_item(key)? {
50+
let val: Value = pythonize::depythonize(&py_val)?;
51+
map.insert(key.clone(), val);
52+
}
53+
}
54+
55+
// Ensure targetingKey is present
56+
if !map.contains_key("targetingKey") {
57+
if let Some(py_val) = context.get_item("targetingKey")? {
58+
let val: Value = pythonize::depythonize(&py_val)?;
59+
map.insert("targetingKey".to_string(), val);
60+
} else {
61+
map.insert("targetingKey".to_string(), Value::String(String::new()));
62+
}
63+
}
64+
65+
// Add $flagd enrichment
66+
let timestamp = std::time::SystemTime::now()
67+
.duration_since(std::time::UNIX_EPOCH)
68+
.unwrap_or_default()
69+
.as_secs();
70+
71+
let mut flagd_props = Map::new();
72+
flagd_props.insert("flagKey".to_string(), Value::String(flag_key.to_string()));
73+
flagd_props.insert("timestamp".to_string(), Value::Number(timestamp.into()));
74+
map.insert("$flagd".to_string(), Value::Object(flagd_props));
75+
76+
Ok(Value::Object(map))
77+
}
78+
79+
/// Resolve the context for evaluation: use filtered context if required keys are
80+
/// known, otherwise fall back to full depythonize.
81+
fn resolve_context(
82+
flag_key: &str,
83+
context: &Bound<'_, PyDict>,
84+
required_context_keys: &HashMap<String, HashSet<String>>,
85+
) -> PyResult<(Value, bool)> {
86+
if let Some(required_keys) = required_context_keys.get(flag_key) {
87+
let filtered = build_filtered_context(flag_key, context, required_keys)?;
88+
Ok((filtered, true))
89+
} else {
90+
let full: Value = pythonize::depythonize(context.as_any())?;
91+
Ok((full, false))
92+
}
93+
}
94+
95+
/// Call the appropriate Rust evaluate method depending on whether context
96+
/// is pre-enriched.
97+
fn do_evaluate(
98+
inner: &::flagd_evaluator::FlagEvaluator,
99+
flag_key: &str,
100+
context: &Value,
101+
pre_enriched: bool,
102+
) -> EvaluationResult {
103+
if pre_enriched {
104+
inner.evaluate_flag_pre_enriched(flag_key, context)
105+
} else {
106+
inner.evaluate_flag(flag_key, context)
107+
}
29108
}
30109

31110
#[pymethods]
@@ -47,6 +126,8 @@ impl FlagEvaluator {
47126

48127
FlagEvaluator {
49128
inner: ::flagd_evaluator::FlagEvaluator::new(mode),
129+
pre_evaluated: HashMap::new(),
130+
required_context_keys: HashMap::new(),
50131
}
51132
}
52133

@@ -77,6 +158,20 @@ impl FlagEvaluator {
77158
))
78159
})?;
79160

161+
// Extract pre-evaluated cache
162+
self.pre_evaluated = response.pre_evaluated.clone().unwrap_or_default();
163+
164+
// Extract required context keys (convert Vec<String> to HashSet<String>)
165+
self.required_context_keys = response
166+
.required_context_keys
167+
.as_ref()
168+
.map(|m| {
169+
m.iter()
170+
.map(|(k, v)| (k.clone(), v.iter().cloned().collect()))
171+
.collect()
172+
})
173+
.unwrap_or_default();
174+
80175
// Convert response to Python dict
81176
pythonize::pythonize(py, &response)
82177
.map(|bound| bound.unbind())
@@ -102,11 +197,23 @@ impl FlagEvaluator {
102197
flag_key: String,
103198
context: &Bound<'_, PyDict>,
104199
) -> PyResult<PyObject> {
105-
// Convert context to JSON Value
106-
let context_value: Value = pythonize::depythonize(context.as_any())?;
200+
// Check pre-evaluation cache first
201+
if let Some(cached) = self.pre_evaluated.get(&flag_key) {
202+
return pythonize::pythonize(py, cached)
203+
.map(|bound| bound.unbind())
204+
.map_err(|e| {
205+
PyErr::new::<pyo3::exceptions::PyValueError, _>(format!(
206+
"Failed to convert result: {}",
207+
e
208+
))
209+
});
210+
}
107211

108-
// Delegate to the Rust FlagEvaluator
109-
let result = self.inner.evaluate_flag(&flag_key, &context_value);
212+
// Resolve context (filtered or full)
213+
let (context_value, pre_enriched) =
214+
resolve_context(&flag_key, context, &self.required_context_keys)?;
215+
216+
let result = do_evaluate(&self.inner, &flag_key, &context_value, pre_enriched);
110217

111218
// Convert result to Python dict
112219
pythonize::pythonize(py, &result)
@@ -134,10 +241,21 @@ impl FlagEvaluator {
134241
context: &Bound<'_, PyDict>,
135242
default_value: bool,
136243
) -> PyResult<bool> {
137-
let context_value: Value = pythonize::depythonize(context.as_any())?;
138-
let result = self.inner.evaluate_bool(&flag_key, &context_value);
244+
// Check pre-evaluation cache first
245+
if let Some(cached) = self.pre_evaluated.get(&flag_key) {
246+
if cached.error_code.is_some() {
247+
return Ok(default_value);
248+
}
249+
return match &cached.value {
250+
Value::Bool(b) => Ok(*b),
251+
_ => Ok(default_value),
252+
};
253+
}
254+
255+
let (context_value, pre_enriched) =
256+
resolve_context(&flag_key, context, &self.required_context_keys)?;
257+
let result = do_evaluate(&self.inner, &flag_key, &context_value, pre_enriched);
139258

140-
// If there's an error, return the default value
141259
if result.error_code.is_some() {
142260
return Ok(default_value);
143261
}
@@ -163,10 +281,21 @@ impl FlagEvaluator {
163281
context: &Bound<'_, PyDict>,
164282
default_value: String,
165283
) -> PyResult<String> {
166-
let context_value: Value = pythonize::depythonize(context.as_any())?;
167-
let result = self.inner.evaluate_string(&flag_key, &context_value);
284+
// Check pre-evaluation cache first
285+
if let Some(cached) = self.pre_evaluated.get(&flag_key) {
286+
if cached.error_code.is_some() {
287+
return Ok(default_value);
288+
}
289+
return match &cached.value {
290+
Value::String(s) => Ok(s.clone()),
291+
_ => Ok(default_value),
292+
};
293+
}
294+
295+
let (context_value, pre_enriched) =
296+
resolve_context(&flag_key, context, &self.required_context_keys)?;
297+
let result = do_evaluate(&self.inner, &flag_key, &context_value, pre_enriched);
168298

169-
// If there's an error, return the default value
170299
if result.error_code.is_some() {
171300
return Ok(default_value);
172301
}
@@ -192,10 +321,21 @@ impl FlagEvaluator {
192321
context: &Bound<'_, PyDict>,
193322
default_value: i64,
194323
) -> PyResult<i64> {
195-
let context_value: Value = pythonize::depythonize(context.as_any())?;
196-
let result = self.inner.evaluate_int(&flag_key, &context_value);
324+
// Check pre-evaluation cache first
325+
if let Some(cached) = self.pre_evaluated.get(&flag_key) {
326+
if cached.error_code.is_some() {
327+
return Ok(default_value);
328+
}
329+
return match &cached.value {
330+
Value::Number(n) => Ok(n.as_i64().unwrap_or(default_value)),
331+
_ => Ok(default_value),
332+
};
333+
}
334+
335+
let (context_value, pre_enriched) =
336+
resolve_context(&flag_key, context, &self.required_context_keys)?;
337+
let result = do_evaluate(&self.inner, &flag_key, &context_value, pre_enriched);
197338

198-
// If there's an error, return the default value
199339
if result.error_code.is_some() {
200340
return Ok(default_value);
201341
}
@@ -221,10 +361,21 @@ impl FlagEvaluator {
221361
context: &Bound<'_, PyDict>,
222362
default_value: f64,
223363
) -> PyResult<f64> {
224-
let context_value: Value = pythonize::depythonize(context.as_any())?;
225-
let result = self.inner.evaluate_float(&flag_key, &context_value);
364+
// Check pre-evaluation cache first
365+
if let Some(cached) = self.pre_evaluated.get(&flag_key) {
366+
if cached.error_code.is_some() {
367+
return Ok(default_value);
368+
}
369+
return match &cached.value {
370+
Value::Number(n) => Ok(n.as_f64().unwrap_or(default_value)),
371+
_ => Ok(default_value),
372+
};
373+
}
374+
375+
let (context_value, pre_enriched) =
376+
resolve_context(&flag_key, context, &self.required_context_keys)?;
377+
let result = do_evaluate(&self.inner, &flag_key, &context_value, pre_enriched);
226378

227-
// If there's an error, return the default value
228379
if result.error_code.is_some() {
229380
return Ok(default_value);
230381
}

0 commit comments

Comments
 (0)