diff --git a/STICKY_ASSIGNMENTS.md b/STICKY_ASSIGNMENTS.md new file mode 100644 index 0000000..2392912 --- /dev/null +++ b/STICKY_ASSIGNMENTS.md @@ -0,0 +1,271 @@ +# Sticky Assignments in Confidence Flag Resolver + +## Overview + +Sticky assignments are a feature in the Confidence Flag Resolver that allows flag assignments to persist across multiple resolve requests. This ensures consistent user experiences and enables advanced experimentation workflows by maintaining assignment state over time. + +## What are Sticky Assignments? + +Sticky assignments work by storing flag assignment information (materializations) that can be referenced in future resolve requests. Instead of randomly assigning users to variants each time a flag is resolved, the system can "stick" to previous assignments when certain conditions are met. + +### Key Concepts + +- **Materialization**: The persisted record of a flag assignment for a specific unit (user/entity) +- **Unit**: The entity being assigned (typically a user ID or targeting key) +- **Materialization Context**: Information about previous assignments passed to the resolver +- **Read/Write Materialization**: Rules specify whether to read from or write to materializations + +## How It Works + +### 1. Materialization Specification + +Each flag rule can include a `MaterializationSpec` that defines: + +```protobuf +message MaterializationSpec { + // Where to read previous assignments from + string read_materialization = 2; + + // Where to write new assignments to + string write_materialization = 1; + + // How materialization reads should be treated + MaterializationReadMode mode = 3; +} +``` + +### 2. Materialization Read Mode + +The `MaterializationReadMode` controls how materializations interact with normal targeting: + +```protobuf +message MaterializationReadMode { + // If true, only units in the materialization will be considered + // If false, units match if they're in materialization OR match segment + bool materialization_must_match = 1; + + // If true, segment targeting is ignored for units in materialization + // If false, both materialization and segment must match + bool segment_targeting_can_be_ignored = 2; +} +``` + +### 3. Resolution Process + +When resolving a flag with sticky assignments enabled: + +1. **Check Dependencies**: Verify all required materializations are available +2. **Read Materialization**: Check if the unit has a previous assignment for this rule +3. **Apply Logic**: Based on `MaterializationReadMode`, determine if the stored assignment should be used +4. **Write Materialization**: If a new assignment is made and a write materialization is specified, store it + +### 4. Materialization Context + +The resolver accepts a `MaterializationContext` containing previous assignments: + +```protobuf +message MaterializationContext { + map unit_materialization_info = 1; +} + +message MaterializationInfo { + bool unit_in_info = 1; + map rule_to_variant = 2; +} +``` + +## Usage Patterns + +### Basic Sticky Assignment + +A rule with both read and write materialization will: +1. Check if the unit was previously assigned +2. Use the previous assignment if available +3. Store new assignments for future use + +### Paused Intake + +Setting `materialization_must_match = true` creates "paused intake": +- Only units already in the materialization will match the rule +- New units will skip this rule entirely +- Useful for controlled rollout scenarios + +### Override Targeting + +Setting `segment_targeting_can_be_ignored = true` allows: +- Units in materialization match the rule regardless of segment targeting +- Segment allocation proportions are ignored for these units +- Useful for maintaining assignments when targeting rules change + +## API Integration + +### Enable Sticky Assignments + +Set `process_sticky = true` in the resolve request: + +```protobuf +message ResolveFlagsRequest { + // ... other fields ... + + // if the resolver should handle sticky assignments + bool process_sticky = 6; + + // Context about the materialization required for the resolve + MaterializationContext materialization_context = 7; + + // if a materialization info is missing, return immediately + bool fail_fast_on_sticky = 8; +} +``` + +### Handling Missing Materializations + +The resolver may return `MissingMaterializations` when required materialization data is unavailable: + +```protobuf +message ResolveFlagResponseResult { + oneof resolve_result { + ResolveFlagsResponse response = 1; + MissingMaterializations missing_materializations = 2; + } +} + +message MissingMaterializationItem { + string unit = 1; + string rule = 2; + string read_materialization = 3; +} +``` + +## Use Cases + +### 1. Consistent User Experience + +Ensure users see the same variant across app sessions and devices by storing their assignments in a shared materialization store. + +### 2. Experiment Analysis + +Maintain assignment consistency during long-running experiments, even when targeting rules or traffic allocation changes. + +### 3. Migration Scenarios + +Gradually migrate users from one variant to another by updating materializations over time. + +### 4. Controlled Rollout + +Use "paused intake" mode to limit new user assignments while maintaining existing ones. + +## Implementation Details + +### Materialization Updates + +When assignments are made, the resolver returns `MaterializationUpdate` objects: + +```protobuf +message MaterializationUpdate { + string unit = 1; + string write_materialization = 2; + string rule = 3; + string variant = 4; +} +``` + +These should be persisted by the client for use in future resolve requests. + +### Error Handling + +- **Missing Materializations**: When required materialization data is unavailable +- **Fail Fast**: `fail_fast_on_sticky` controls whether to return immediately or continue processing +- **Dependency Checking**: The resolver validates all materialization dependencies before evaluation + +## Advanced Optimizations + +### Fail Fast on First Missing Materialization + +The `fail_fast_on_sticky` parameter provides a performance optimization for handling missing materializations: + +**Behavior:** +- When `fail_fast_on_sticky = true`: As soon as any flag encounters a missing materialization dependency, the resolver immediately returns all accumulated missing materializations without processing remaining flags +- When `fail_fast_on_sticky = false`: The resolver continues processing all flags and collects all missing materializations before returning + +**Use Cases:** +- **Discovery Mode**: Set to `false` when you want to collect all missing materializations across all flags in a single request +- **Production Mode**: Set to `true` when you want immediate feedback about missing dependencies to avoid unnecessary processing + +**Example Flow:** +``` +Flag A: ✅ Has materialization → Process normally +Flag B: ❌ Missing materialization + fail_fast=true → Return immediately with [Flag B missing item] +Flag C: (Not processed due to fail_fast) +``` + +### Rule Evaluation Skipping Optimization + +The resolver implements a sophisticated optimization to avoid unnecessary rule evaluation when materialization dependencies are missing: + +**The `skip_on_not_missing` Mechanism:** + +1. **Dependency Discovery Phase**: When processing multiple flags, if any previous flag had missing materializations, subsequent flags enter "discovery mode" + +2. **Two-Pass Evaluation**: + - **Pass 1**: Check for missing materializations only (skip rule evaluation) + - **Pass 2**: If all materializations are available, re-evaluate with full rule processing + +3. **Optimization Logic**: + ```rust + skip_on_not_missing: !missing_materialization_items.is_empty() + ``` + +**How It Works:** + +``` +Processing Flag 1: +├── Rule 1: Missing materialization X → Collect missing item +├── Rule 2: Skip evaluation (skip_on_not_missing=true) +├── Result: Flag 1 has missing materializations + +Processing Flag 2: +├── skip_on_not_missing = true (because Flag 1 had missing deps) +├── All rules: Only check for missing materializations, don't evaluate +├── Result: Collect any additional missing items for Flag 2 +``` + +**Benefits:** +- **Performance**: Avoids expensive rule evaluation (segment matching, bucket calculation) when dependencies are missing +- **Consistency**: Ensures all missing materializations are discovered before any rule evaluation begins +- **Atomicity**: Either all flags resolve successfully with their materializations, or all missing dependencies are returned + +**Complete Resolution Flow:** + +1. **First Pass**: Process all flags in discovery mode to find all missing materializations +2. **Early Return**: If `fail_fast_on_sticky=true` and missing deps found, return immediately +3. **Second Pass**: If all materializations available, re-process all flags with full evaluation +4. **Success**: Return resolved flags with materialization updates + +This optimization ensures efficient handling of complex dependency graphs while maintaining correctness and performance. + +### Performance Considerations + +- **Materialization lookups happen before rule evaluation**: Dependencies are checked first to avoid expensive operations +- **Failed materialization dependencies skip rule evaluation**: No segment matching or bucket calculation when deps missing +- **Two-phase resolution**: Discovery phase finds all missing deps, evaluation phase only runs when all deps available +- **Batch processing**: Multiple flags can share materialization context for efficient processing + +## Best Practices + +1. **Consistent Storage**: Use reliable storage for materialization data to ensure assignment consistency +2. **Version Management**: Consider materialization versioning for complex migration scenarios +3. **Monitoring**: Track materialization hit rates and assignment consistency +4. **Testing**: Verify sticky behavior with different materialization states +5. **Cleanup**: Implement materialization cleanup for archived flags or expired experiments + +## Example Workflow + +1. User requests flag resolution without materialization context +2. Resolver assigns variants and returns `MaterializationUpdate`s +3. Client stores materialization data +4. Subsequent requests include `MaterializationContext` +5. Resolver uses stored assignments when available, creating new ones as needed +6. Process continues with updated materialization context + +This approach ensures assignment consistency while allowing new users to be assigned according to current targeting rules. diff --git a/confidence-cloudflare-resolver/src/lib.rs b/confidence-cloudflare-resolver/src/lib.rs index 4e29654..169c606 100644 --- a/confidence-cloudflare-resolver/src/lib.rs +++ b/confidence-cloudflare-resolver/src/lib.rs @@ -33,7 +33,7 @@ static FLAGS_LOGS_QUEUE: OnceLock = OnceLock::new(); static CONFIDENCE_CLIENT_ID: OnceLock = OnceLock::new(); static CONFIDENCE_CLIENT_SECRET: OnceLock = OnceLock::new(); -static FLAG_LOGGER: Lazy = Lazy::new(|| Logger::new()); +static FLAG_LOGGER: Lazy = Lazy::new(Logger::new); static RESOLVER_STATE: Lazy = Lazy::new(|| { ResolverState::from_proto(STATE_JSON.to_owned().try_into().unwrap(), ACCOUNT_ID).unwrap() @@ -192,9 +192,7 @@ pub async fn main(req: Request, env: Env, _ctx: Context) -> Result { &Bytes::from(STANDARD.decode(ENCRYPTION_KEY_BASE64).unwrap()), ) { Ok(resolver) => match resolver.apply_flags(&apply_flag_req) { - Ok(()) => { - return Response::from_json(&ApplyFlagsResponse::default()); - } + Ok(()) => Response::from_json(&ApplyFlagsResponse::default()), Err(msg) => { Response::error(msg, 500)?.with_cors_headers(&allowed_origin) } diff --git a/confidence-resolver/build.rs b/confidence-resolver/build.rs index 8769daa..297e5c5 100644 --- a/confidence-resolver/build.rs +++ b/confidence-resolver/build.rs @@ -9,6 +9,7 @@ fn main() -> Result<()> { root.join("confidence/flags/admin/v1/resolver.proto"), root.join("confidence/flags/resolver/v1/api.proto"), root.join("confidence/flags/resolver/v1/internal_api.proto"), + root.join("confidence/flags/resolver/v1/wasm_api.proto"), root.join("confidence/flags/resolver/v1/events/events.proto"), ]; diff --git a/confidence-resolver/protos/confidence/flags/resolver/v1/wasm_api.proto b/confidence-resolver/protos/confidence/flags/resolver/v1/wasm_api.proto new file mode 100644 index 0000000..73c6cd3 --- /dev/null +++ b/confidence-resolver/protos/confidence/flags/resolver/v1/wasm_api.proto @@ -0,0 +1,68 @@ +syntax = "proto3"; + +package confidence.flags.resolver.v1; + +import "google/api/resource.proto"; +import "google/api/annotations.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +import "confidence/api/annotations.proto"; +import "confidence/flags/types/v1/types.proto"; +import "confidence/flags/resolver/v1/types.proto"; +import "confidence/flags/resolver/v1/api.proto"; + +option java_package = "com.spotify.confidence.flags.resolver.v1"; +option java_multiple_files = true; +option java_outer_classname = "WasmApiProto"; + + +message ResolveWithStickyRequest { + ResolveFlagsRequest resolve_request = 1; + + // Context about the materialization required for the resolve + MaterializationContext materialization_context = 7; + + // if a materialization info is missing, we want tor return to the caller immediately + bool fail_fast_on_sticky = 8; +} + +message MaterializationContext { + map unit_materialization_info = 1; +} + +message MaterializationInfo { + bool unit_in_info = 1; + map rule_to_variant = 2; +} + +message ResolveWithStickyResponse { + oneof resolve_result { + Success success = 1; + MissingMaterializations missing_materializations = 2; + } + + message Success { + ResolveFlagsResponse response = 1; + repeated MaterializationUpdate updates = 2; + } + + message MissingMaterializations { + repeated MissingMaterializationItem items = 1; + } + + message MissingMaterializationItem { + string unit = 1; + string rule = 2; + string read_materialization = 3; + } + + message MaterializationUpdate { + string unit = 1; + string write_materialization = 2; + string rule = 3; + string variant = 4; + } +} + diff --git a/confidence-resolver/src/lib.rs b/confidence-resolver/src/lib.rs index 5309ba5..3f3d03b 100644 --- a/confidence-resolver/src/lib.rs +++ b/confidence-resolver/src/lib.rs @@ -53,6 +53,13 @@ use flags_types::Expression; use gzip::decompress_gz; use crate::err::{ErrorCode, OrFailExt}; +use crate::proto::confidence::flags::resolver::v1::resolve_with_sticky_response::{ + MaterializationUpdate, ResolveResult, +}; +use crate::proto::confidence::flags::resolver::v1::{ + resolve_with_sticky_response, MaterializationContext, ResolveFlagsRequest, + ResolveFlagsResponse, ResolveWithStickyRequest, ResolveWithStickyResponse, +}; impl TryFrom> for ResolverStatePb { type Error = ErrorCode; @@ -207,7 +214,7 @@ pub struct EvaluationContext { pub context: Struct, } pub struct FlagToApply { - pub assigned_flag: flags_resolver::resolve_token_v1::AssignedFlag, + pub assigned_flag: AssignedFlag, pub skew_adjusted_applied_time: Timestamp, } @@ -389,6 +396,74 @@ pub struct AccountResolver<'a, H: Host> { host: PhantomData, } +#[derive(Debug)] +pub enum ResolveFlagError { + Message(String), + MissingMaterializations(), +} + +impl ResolveFlagError { + fn message(&self) -> String { + match self { + ResolveFlagError::Message(msg) => msg.clone(), + ResolveFlagError::MissingMaterializations() => "Missing materializations".to_string(), + } + } + + pub fn err(message: &str) -> ResolveFlagError { + ResolveFlagError::Message(message.to_string()) + } + + pub fn missing_materializations() -> ResolveFlagError { + ResolveFlagError::MissingMaterializations() + } +} + +impl From for String { + fn from(value: ResolveFlagError) -> Self { + value.message().to_string() + } +} + +impl From for ResolveFlagError { + fn from(value: ErrorCode) -> Self { + ResolveFlagError::err(format!("error code {}", &value.to_string()).as_str()) + } +} + +impl ResolveWithStickyResponse { + fn with_success(response: ResolveFlagsResponse, updates: Vec) -> Self { + ResolveWithStickyResponse { + resolve_result: Some(ResolveResult::Success( + resolve_with_sticky_response::Success { + response: Some(response), + updates, + }, + )), + } + } + + fn with_missing_materializations( + items: Vec, + ) -> Self { + ResolveWithStickyResponse { + resolve_result: Some(ResolveResult::MissingMaterializations( + resolve_with_sticky_response::MissingMaterializations { items }, + )), + } + } +} + +impl ResolveWithStickyRequest { + fn without_sticky(resolve_request: ResolveFlagsRequest) -> ResolveWithStickyRequest { + ResolveWithStickyRequest { + resolve_request: Some(resolve_request), + fail_fast_on_sticky: false, + materialization_context: Some(MaterializationContext::default()), + } + } +} + impl<'a, H: Host> AccountResolver<'a, H> { pub fn new( client: &'a Client, @@ -405,13 +480,14 @@ impl<'a, H: Host> AccountResolver<'a, H> { } } - pub fn resolve_flags( + pub fn resolve_flags_sticky( &self, - request: &flags_resolver::ResolveFlagsRequest, - ) -> Result { + request: &flags_resolver::ResolveWithStickyRequest, + ) -> Result { let timestamp = H::current_time(); - let flag_names = &request.flags; + let resolve_request = &request.resolve_request.clone().or_fail()?; + let flag_names = resolve_request.flags.clone(); let flags_to_resolve = self .state .flags @@ -434,24 +510,59 @@ impl<'a, H: Host> AccountResolver<'a, H> { } } - let resolved_values = flags_to_resolve + let mut resolve_results = Vec::with_capacity(flags_to_resolve.len()); + + for flag in flags_to_resolve { + let resolve_result = self.resolve_flag(flag, request.materialization_context.clone()); + match resolve_result { + Ok(resolve_result) => resolve_results.push(resolve_result), + Err(err) => { + return match err { + ResolveFlagError::Message(msg) => Err(msg.to_string()), + ResolveFlagError::MissingMaterializations() => { + // we want to fallback on online resolver, return early + if request.fail_fast_on_sticky { + Ok(ResolveWithStickyResponse::with_missing_materializations( + vec![], + )) + } else { + let deps = self.collect_missing_materializations(&flag); + match deps { + Ok(deps) => Ok( + ResolveWithStickyResponse::with_missing_materializations( + deps, + ), + ), + Err(err) => Err(err), + } + } + } + }; + } + } + } + + let resolved_values: Vec = resolve_results .iter() - .map(|flag| { - self.resolve_flag(flag) - .map_err(|e| format!("{}: {}", flag.name, e)) - }) - .collect::, _>>()?; + .map(|r| r.resolved_value.clone()) + .collect(); let resolve_id = H::random_alphanumeric(32); let mut response = flags_resolver::ResolveFlagsResponse { resolve_id: resolve_id.clone(), ..Default::default() }; + let mut updates: Vec = vec![]; for resolved_value in &resolved_values { response.resolved_flags.push(resolved_value.into()); } - if request.apply { + // Collect all materialization updates from all resolve results + for resolve_result in &resolve_results { + updates.extend(resolve_result.updates.clone()); + } + + if resolve_request.apply { let flags_to_apply: Vec = resolved_values .iter() .filter(|v| v.should_apply) @@ -466,7 +577,7 @@ impl<'a, H: Host> AccountResolver<'a, H> { &self.evaluation_context.context, flags_to_apply.as_slice(), self.client, - &request.sdk, + &resolve_request.sdk.clone(), ); } else { // create resolve token @@ -490,20 +601,54 @@ impl<'a, H: Host> AccountResolver<'a, H> { let encrypted_token = self .encrypt_resolve_token(&resolve_token) - .map_err(|_| "Failed to encrypt resolve token".to_string())?; + .map_err(|_| "Failed to encrypt resolve token".to_string()) + .or_fail()?; response.resolve_token = encrypted_token; } + let owned_values: Vec = + resolved_values.iter().map(|v| (*v).clone()).collect(); H::log_resolve( &resolve_id, &self.evaluation_context.context, - resolved_values.as_slice(), + owned_values.as_slice(), self.client, - &request.sdk, + &resolve_request.sdk.clone(), ); - Ok(response) + Ok(ResolveWithStickyResponse::with_success(response, updates)) + } + + pub fn resolve_flags( + &self, + request: &flags_resolver::ResolveFlagsRequest, + ) -> Result { + let response = self.resolve_flags_sticky(&ResolveWithStickyRequest::without_sticky( + flags_resolver::ResolveFlagsRequest { + flags: request.flags.clone(), + sdk: request.sdk.clone(), + evaluation_context: request.evaluation_context.clone(), + client_secret: request.client_secret.clone(), + apply: request.apply, + }, + )); + + match response { + Ok(v) => match v.resolve_result { + None => Err("failed to resolve flags".to_string()), + Some(r) => match r { + ResolveResult::Success(flags_response) => match flags_response.response { + Some(flags_response) => Ok(flags_response), + None => Err("failed to resolve flags".to_string()), + }, + ResolveResult::MissingMaterializations(_) => { + Err("sticky assignments is not supported".to_string()) + } + }, + }, + Err(e) => Err(e), + } } pub fn apply_flags(&self, request: &flags_resolver::ApplyFlagsRequest) -> Result<(), String> { @@ -569,19 +714,76 @@ impl<'a, H: Host> AccountResolver<'a, H> { _ => Err("TargetingKeyError".to_string()), } } - pub fn resolve_flag_name(&'a self, flag_name: &str) -> Result, String> { + pub fn resolve_flag_name( + &'a self, + flag_name: &str, + ) -> Result, ResolveFlagError> { self.state .flags .get(flag_name) - .ok_or("flag not found".to_string()) - .and_then(|flag| self.resolve_flag(flag)) + .ok_or(ResolveFlagError::err("flag not found")) + .and_then(|flag| self.resolve_flag(flag, None)) + } + + pub fn collect_missing_materializations( + &'a self, + flag: &'a Flag, + ) -> Result, String> { + let mut missing_materializations: Vec< + resolve_with_sticky_response::MissingMaterializationItem, + > = Vec::new(); + + if flag.state == flags_admin::flag::State::Archived as i32 { + return Ok(vec![]); + } + + for rule in &flag.rules { + if !rule.enabled { + continue; + } + + if let Some(materialization_spec) = &rule.materialization_spec { + let rule_name = &rule.name.as_str(); + let read_materialization = materialization_spec.read_materialization.as_str(); + if !read_materialization.is_empty() { + let targeting_key = if !rule.targeting_key_selector.is_empty() { + rule.targeting_key_selector.as_str() + } else { + TARGETING_KEY + }; + let unit: String = match self.get_targeting_key(targeting_key) { + Ok(Some(u)) => u, + Ok(None) => continue, + Err(_) => return Err("Targeting key error".to_string()), + }; + missing_materializations.push( + resolve_with_sticky_response::MissingMaterializationItem { + unit, + rule: rule_name.to_string(), + read_materialization: read_materialization.to_string(), + }, + ); + continue; + } + } + } + + Ok(missing_materializations) } - pub fn resolve_flag(&'a self, flag: &'a Flag) -> Result, String> { + pub fn resolve_flag( + &'a self, + flag: &'a Flag, + sticky_context: Option, + ) -> Result, ResolveFlagError> { + let mut updates: Vec = Vec::new(); let mut resolved_value = ResolvedValue::new(flag); if flag.state == flags_admin::flag::State::Archived as i32 { - return Ok(resolved_value.error(ResolveReason::FlagArchived)); + return Ok(FlagResolveResult { + resolved_value: resolved_value.error(ResolveReason::FlagArchived), + updates: vec![], + }); } for rule in &flag.rules { @@ -604,17 +806,90 @@ impl<'a, H: Host> AccountResolver<'a, H> { let unit: String = match self.get_targeting_key(targeting_key) { Ok(Some(u)) => u, Ok(None) => continue, - Err(_) => return Ok(resolved_value.error(ResolveReason::TargetingKeyError)), + Err(_) => { + return Ok(FlagResolveResult { + resolved_value: resolved_value.error(ResolveReason::TargetingKeyError), + updates: vec![], + }) + } }; - if !self.segment_match(segment, &unit)? { - // ResolveReason::SEGMENT_NOT_MATCH + let Some(spec) = &rule.assignment_spec else { continue; + }; + + let mut materialization_matched = false; + if let Some(materialization_spec) = &rule.materialization_spec { + if let Some(context) = &sticky_context { + let read_materialization = &materialization_spec.read_materialization; + if !read_materialization.is_empty() { + if let Some(info) = context.unit_materialization_info.get(&unit) { + materialization_matched = if !info.unit_in_info { + if materialization_spec + .mode + .as_ref() + .map(|mode| mode.materialization_must_match) + .unwrap_or(false) + { + // Materialization must match but unit is not in materialization + continue; + } + false + } else if materialization_spec + .mode + .as_ref() + .map(|mode| mode.segment_targeting_can_be_ignored) + .unwrap_or(false) + { + true + } else { + self.segment_match(segment, &unit)? + }; + if materialization_matched { + if let Some(variant_name) = info.rule_to_variant.get(&rule.name) { + if let Some(assignment) = + spec.assignments.iter().find(|assignment| { + if let Some(rule::assignment::Assignment::Variant( + ref variant_assignment, + )) = &assignment.assignment + { + variant_assignment.variant == *variant_name + } else { + false + } + }) + { + let variant = flag + .variants + .iter() + .find(|v| v.name == *variant_name) + .or_fail()?; + return Ok(FlagResolveResult { + resolved_value: resolved_value.with_variant_match( + rule, + segment, + variant, + &assignment.assignment_id, + &unit, + ), + updates: vec![], + }); + } + } + } + } else { + materialization_matched = false; + }; + } + } else { + return Err(ResolveFlagError::missing_materializations()); + } } - let Some(spec) = &rule.assignment_spec else { + if !materialization_matched && !self.segment_match(segment, &unit)? { + // ResolveReason::SEGMENT_NOT_MATCH continue; - }; + } let bucket_count = spec.bucket_count; let variant_salt = segment_name.split("/").nth(1).or_fail()?; let key = format!("{}|{}", variant_salt, unit); @@ -627,10 +902,34 @@ impl<'a, H: Host> AccountResolver<'a, H> { .any(|range| range.lower <= bucket && bucket < range.upper) }); + let has_write_spec = rule + .materialization_spec + .as_ref() + .map(|materialization_spec| &materialization_spec.write_materialization); + if let Some(assignment) = matched_assignment { let Some(a) = &assignment.assignment else { continue; }; + + // Extract variant name from assignment if it's a variant assignment + let variant_name = match a { + rule::assignment::Assignment::Variant(ref variant_assignment) => { + variant_assignment.variant.clone() + } + _ => "".to_string(), + }; + + // write the materialization info if write spec exists + if let Some(write_spec) = has_write_spec { + updates.push(MaterializationUpdate { + write_materialization: write_spec.to_string(), + unit: unit.to_string(), + rule: rule.clone().name, + variant: variant_name, + }) + } + match a { rule::assignment::Assignment::Fallthrough(_) => { resolved_value.attribute_fallthrough_rule( @@ -641,12 +940,15 @@ impl<'a, H: Host> AccountResolver<'a, H> { continue; } rule::assignment::Assignment::ClientDefault(_) => { - return Ok(resolved_value.with_client_default_match( - rule, - segment, - &assignment.assignment_id, - &unit, - )) + return Ok(FlagResolveResult { + resolved_value: resolved_value.with_client_default_match( + rule, + segment, + &assignment.assignment_id, + &unit, + ), + updates, + }) } rule::assignment::Assignment::Variant( rule::assignment::VariantAssignment { @@ -659,13 +961,16 @@ impl<'a, H: Host> AccountResolver<'a, H> { .find(|variant| variant.name == *variant_name) .or_fail()?; - return Ok(resolved_value.with_variant_match( - rule, - segment, - variant, - &assignment.assignment_id, - &unit, - )); + return Ok(FlagResolveResult { + resolved_value: resolved_value.with_variant_match( + rule, + segment, + variant, + &assignment.assignment_id, + &unit, + ), + updates, + }); } }; } @@ -677,10 +982,13 @@ impl<'a, H: Host> AccountResolver<'a, H> { resolved_value.should_apply = !resolved_value.fallthrough_rules.is_empty(); } - Ok(resolved_value) + Ok(FlagResolveResult { + resolved_value, + updates, + }) } - /// Get an attribute value from the [EvaluationContext] struct, adressed by a path specification. + /// Get an attribute value from the [EvaluationContext] struct, addressed by a path specification. /// If the struct is `{user:{name:"roug",id:42}}`, then getting the `"user.name"` field will return /// the value `"roug"`. pub fn get_attribute_value(&self, field_path: &str) -> &Value { @@ -848,7 +1156,7 @@ fn list_wrapper(value: &targeting::value::Value) -> targeting::ListValue { } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ResolvedValue<'a> { pub flag: &'a Flag, pub reason: ResolveReason, @@ -857,6 +1165,12 @@ pub struct ResolvedValue<'a> { pub should_apply: bool, } +#[derive(Debug)] +pub struct FlagResolveResult<'a> { + pub resolved_value: ResolvedValue<'a>, + pub updates: Vec, +} + impl<'a> ResolvedValue<'a> { fn new(flag: &'a Flag) -> Self { ResolvedValue { @@ -936,7 +1250,7 @@ impl<'a> From<&ResolvedValue<'a>> for flags_resolver::ResolvedFlag { fn from(value: &ResolvedValue<'a>) -> Self { let mut resolved_flag = flags_resolver::ResolvedFlag { flag: value.flag.name.clone(), - reason: value.reason.clone() as i32, + reason: value.reason as i32, should_apply: value.should_apply, ..Default::default() }; @@ -965,7 +1279,7 @@ impl<'a> From<&ResolvedValue<'a>> for flags_resolver::resolve_token_v1::Assigned fn from(value: &ResolvedValue<'a>) -> Self { let mut assigned_flag = flags_resolver::resolve_token_v1::AssignedFlag { flag: value.flag.name.clone(), - reason: value.reason.clone() as i32, + reason: value.reason as i32, fallthrough_assignments: value .fallthrough_rules .iter() @@ -1000,7 +1314,7 @@ impl<'a> From<&ResolvedValue<'a>> for flags_resolver::resolve_token_v1::Assigned } } -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct AssignmentMatch<'a> { pub rule: &'a Rule, pub segment: &'a Segment, @@ -1017,7 +1331,7 @@ pub struct FallthroughRule<'a> { } // note that the ordinal values are set to match the corresponding protobuf enum -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ResolveReason { // The flag was successfully resolved because one rule matched. Match = 1, @@ -1156,8 +1470,9 @@ mod tests { .get_resolver_with_json_context(SECRET, context_json, &ENCRYPTION_KEY) .unwrap(); let flag = resolver.state.flags.get("flags/tutorial-feature").unwrap(); - let resolved_value = resolver.resolve_flag(flag).unwrap(); - let assignment_match = resolved_value.assignment_match.unwrap(); + let resolve_result = resolver.resolve_flag(flag, None).unwrap(); + let resolved_value = &resolve_result.resolved_value; + let assignment_match = resolved_value.assignment_match.as_ref().unwrap(); assert_eq!( assignment_match.rule.name, @@ -1177,8 +1492,9 @@ mod tests { .unwrap(); let flag = resolver.state.flags.get("flags/tutorial-feature").unwrap(); let assignment_match = resolver - .resolve_flag(flag) + .resolve_flag(flag, None) .unwrap() + .resolved_value .assignment_match .unwrap(); @@ -1573,10 +1889,11 @@ mod tests { .flags .get("flags/fallthrough-test-2") .unwrap(); - let resolved_value = resolver.resolve_flag(flag).unwrap(); + let resolve_result = resolver.resolve_flag(flag, None).unwrap(); + let resolved_value = &resolve_result.resolved_value; assert_eq!(resolved_value.reason as i32, ResolveReason::Match as i32); - let assignment_match = resolved_value.assignment_match.unwrap(); + let assignment_match = resolved_value.assignment_match.as_ref().unwrap(); assert_eq!(assignment_match.targeting_key, "26"); } @@ -1599,7 +1916,8 @@ mod tests { .flags .get("flags/fallthrough-test-2") .unwrap(); - let resolved_value = resolver.resolve_flag(flag).unwrap(); + let resolve_result = resolver.resolve_flag(flag, None).unwrap(); + let resolved_value = &resolve_result.resolved_value; assert_eq!( resolved_value.reason as i32, diff --git a/wasm/rust-guest/src/lib.rs b/wasm/rust-guest/src/lib.rs index 478b91e..c55c042 100644 --- a/wasm/rust-guest/src/lib.rs +++ b/wasm/rust-guest/src/lib.rs @@ -9,7 +9,9 @@ use prost::Message; #[global_allocator] static ALLOC: wee_alloc::WeeAlloc = wee_alloc::WeeAlloc::INIT; -use confidence_resolver::proto::confidence::flags::resolver::v1::WriteFlagLogsRequest; +use confidence_resolver::proto::confidence::flags::resolver::v1::{ + ResolveWithStickyRequest, WriteFlagLogsRequest, +}; use confidence_resolver::resolve_logger::ResolveLogger; use rand::distr::Alphanumeric; use rand::distr::SampleString; @@ -29,7 +31,7 @@ use confidence_resolver::{ proto::{ confidence::flags::admin::v1::ResolverState as ResolverStatePb, confidence::flags::resolver::v1::{ - ResolveFlagsRequest, ResolveFlagsResponse, ResolvedFlag, Sdk, + ResolveFlagsRequest, ResolveFlagsResponse, ResolveWithStickyResponse, ResolvedFlag, Sdk, }, google::{Struct, Timestamp}, }, @@ -184,6 +186,14 @@ wasm_msg_guest! { Ok(VOID) } + fn resolve_with_sticky(request: ResolveWithStickyRequest) -> WasmResult { + let resolver_state = get_resolver_state()?; + let resolve_request = &request.resolve_request.clone().unwrap(); + let evaluation_context = resolve_request.evaluation_context.clone().unwrap(); + let resolver = resolver_state.get_resolver::(resolve_request.client_secret.as_str(), evaluation_context, &ENCRYPTION_KEY)?; + resolver.resolve_flags_sticky(&request).into() + } + fn resolve(request: ResolveFlagsRequest) -> WasmResult { let resolver_state = get_resolver_state()?; let evaluation_context = request.evaluation_context.as_ref().cloned().unwrap_or_default(); @@ -194,8 +204,8 @@ wasm_msg_guest! { let resolver_state = get_resolver_state()?; let evaluation_context = request.evaluation_context.as_ref().cloned().unwrap_or_default(); let resolver = resolver_state.get_resolver::(&request.client_secret, evaluation_context, &ENCRYPTION_KEY).unwrap(); - let resolved_value = resolver.resolve_flag_name(&request.name)?; - Ok((&resolved_value).into()) + let resolve_result = resolver.resolve_flag_name(&request.name)?; + Ok((&resolve_result.resolved_value).into()) } fn flush_logs(_request:Void) -> WasmResult { LOGGER.checkpoint().map_err(|e| e.into())