diff --git a/crates/trusted-server-adapter-fastly/src/app.rs b/crates/trusted-server-adapter-fastly/src/app.rs index e2ec7a370..fcb4c2856 100644 --- a/crates/trusted-server-adapter-fastly/src/app.rs +++ b/crates/trusted-server-adapter-fastly/src/app.rs @@ -108,7 +108,6 @@ use trusted_server_core::auction::{ use trusted_server_core::cache_policy::EdgeCacheHeader; use trusted_server_core::config_payload::DEFAULT_SECRET_STORE_ID; use trusted_server_core::constants::{COOKIE_SHAREDID, COOKIE_TS_EIDS}; -use trusted_server_core::ec::EcContext; use trusted_server_core::ec::admin::{ deny_admin_diagnostic_fallback, handle_admin_ec_lookup, handle_admin_eids_lookup, }; @@ -118,6 +117,7 @@ use trusted_server_core::ec::device::DeviceSignals; use trusted_server_core::ec::identify::{cors_preflight_identify, handle_identify}; use trusted_server_core::ec::kv::KvIdentityGraph; use trusted_server_core::ec::registry::PartnerRegistry; +use trusted_server_core::ec::{EcContext, EidSyncSource}; use trusted_server_core::error::{IntoHttpResponse as _, TrustedServerError}; use trusted_server_core::http_util::is_navigation_request; use trusted_server_core::integrations::{ @@ -645,6 +645,7 @@ async fn run_named_route( NamedRouteHandler::SetTester => handle_set_tester(&state.settings), NamedRouteHandler::ClearTester => handle_clear_tester(&state.settings), NamedRouteHandler::Auction => { + ec.ec_context.set_eid_sync_source(EidSyncSource::Auction); let partner_registry = PartnerRegistry::from_config(&state.settings.ec.partners)?; let registry_ref = if partner_registry.is_empty() { None @@ -817,7 +818,11 @@ async fn dispatch_fallback( // Generate an EC ID if needed — mirrors the legacy catch-all arm. // Only for document navigations by recognised browsers; subresource // requests may lack consent signals such as Sec-GPC. - let is_publisher_navigation = ec.is_real_browser && is_navigation_request(&req); + let is_navigation = is_navigation_request(&req); + if is_navigation { + ec.ec_context.set_eid_sync_source(EidSyncSource::Navigation); + } + let is_publisher_navigation = ec.is_real_browser && is_navigation; if is_publisher_navigation && let Err(err) = ec .ec_context @@ -1326,8 +1331,8 @@ mod tests { use std::time::Duration; use super::{ - AppState, AuctionDispatch, EcContext, EdgeCacheHeader, HandlerFuture, NAMED_ROUTES, - NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, RuntimeStoreConfig, + AppState, AuctionDispatch, EcContext, EdgeCacheHeader, EidSyncSource, HandlerFuture, + NAMED_ROUTES, NamedRouteHandler, PAGE_BIDS_LEGACY_PATH, PAGE_BIDS_PATH, RuntimeStoreConfig, TrustedServerApp, build_orchestrator_with_plan, build_per_request_services, build_state_from_settings, compile_auction_plan, handle_publisher_request, publisher_response_into_streaming_response, startup_error_router, @@ -1338,7 +1343,9 @@ mod tests { use edgezero_core::body::Body; use edgezero_core::context::RequestContext; use edgezero_core::env_config::EnvConfig; - use edgezero_core::http::{Method, Response, StatusCode, header, request_builder}; + use edgezero_core::http::{ + HeaderValue, Method, Request, Response, StatusCode, header, request_builder, + }; use edgezero_core::key_value_store::NoopKvStore; use edgezero_core::params::PathParams; use edgezero_core::router::RouterService; @@ -2216,6 +2223,95 @@ mod tests { ); } + fn browser_request(method: Method, path: &str, fetch_destination: &str) -> Request { + let mut request = empty_request(method, path); + request.headers_mut().insert( + "sec-fetch-dest", + HeaderValue::from_bytes(fetch_destination.as_bytes()) + .expect("should parse fetch destination"), + ); + request.extensions_mut().insert(DeviceSignals::derive( + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 \ + (KHTML, like Gecko) Chrome/146.0.0.0 Safari/537.36", + Some("t13d1516h2_8daaf6152771_b186095e22b6"), + Some("1:65536;2:0;4:6291456;6:262144"), + )); + request + } + + fn eid_sync_source_of(response: &Response) -> Option { + response + .extensions() + .get::() + .expect("response should carry EC finalization state") + .ec_context + .eid_sync_source() + } + + #[test] + fn dispatch_limits_returning_user_eid_sync_to_eligible_routes() { + let router = test_router(); + + let navigation = route( + &router, + browser_request(Method::GET, "/article", "document"), + ); + assert_eq!( + eid_sync_source_of(&navigation), + Some(EidSyncSource::Navigation) + ); + + let mut navigation_without_browser_signals = + browser_request(Method::GET, "/another-article", "document"); + navigation_without_browser_signals + .extensions_mut() + .remove::(); + let navigation_without_browser_signals = route(&router, navigation_without_browser_signals); + assert_eq!( + eid_sync_source_of(&navigation_without_browser_signals), + Some(EidSyncSource::Navigation), + "route classification should not depend on EC generation's browser gate" + ); + + let auction = route(&router, browser_request(Method::POST, "/auction", "empty")); + assert_eq!(eid_sync_source_of(&auction), Some(EidSyncSource::Auction)); + + let mut page_bids_request = browser_request(Method::GET, "/_ts/page-bids", "empty"); + page_bids_request + .headers_mut() + .insert("sec-fetch-site", HeaderValue::from_static("same-origin")); + let page_bids = route(&router, page_bids_request); + assert_eq!( + eid_sync_source_of(&page_bids), + Some(EidSyncSource::PageBids), + "an admitted SPA page-bids request should persist returning-user EID cookies" + ); + + let mut denied_page_bids_request = browser_request(Method::GET, "/_ts/page-bids", "empty"); + denied_page_bids_request + .headers_mut() + .insert("sec-fetch-site", HeaderValue::from_static("cross-site")); + let denied_page_bids = route(&router, denied_page_bids_request); + assert_eq!( + eid_sync_source_of(&denied_page_bids), + None, + "a denied cross-site page-bids request must not persist EID cookies" + ); + + for request in [ + browser_request(Method::GET, "/static/tsjs=prebid", "script"), + browser_request(Method::GET, "/analytics.gif", "image"), + browser_request(Method::GET, "/integrations/prebid/bundle.js", "script"), + ] { + let response = route(&router, request); + assert_eq!( + eid_sync_source_of(&response), + None, + "static, analytics, and integration requests must not persist EID cookies" + ); + } + } + #[test] fn browser_device_signals_from_extension_reach_ec_finalize_state() { // Regression guard for the EdgeZero JA4/H2 signal loss: `edgezero_main` @@ -3129,22 +3225,40 @@ mod tests { } #[test] - fn filter_short_circuit_response_is_not_recovery_eligible() { + fn filter_short_circuit_response_is_not_eligible_for_eid_persistence() { // A request-filter short circuit (e.g. a DataDome challenge/block) must - // not authorize orphan recovery even for a would-be publisher - // navigation: no publisher page was served. + // not authorize orphan recovery or EID persistence. No publisher page + // or auction was served, so the challenged request must not write EIDs. + // Explicit consent withdrawal remains independently eligible. let router = router_with_request_filters(vec![Arc::new(ChallengeRequestFilter)]); - let response = route(&router, browser_navigation_request("/some-page")); + let navigation = route(&router, browser_navigation_request("/some-page")); assert_eq!( - response.status(), + navigation.status(), StatusCode::FORBIDDEN, - "the challenge filter should short-circuit routing" + "the challenge filter should short-circuit navigation routing" ); assert!( - !recovery_eligible_of(&response), + !recovery_eligible_of(&navigation), "a short-circuit filter response must not authorize orphan recovery" ); + assert_eq!( + eid_sync_source_of(&navigation), + None, + "a challenged navigation must not authorize EID persistence" + ); + + let auction = route(&router, browser_request(Method::POST, "/auction", "empty")); + assert_eq!( + auction.status(), + StatusCode::FORBIDDEN, + "the challenge filter should short-circuit auction routing" + ); + assert_eq!( + eid_sync_source_of(&auction), + None, + "a challenged auction must not authorize EID persistence" + ); } #[test] diff --git a/crates/trusted-server-core/src/auction/endpoints.rs b/crates/trusted-server-core/src/auction/endpoints.rs index c0c0a7792..ab3585e3d 100644 --- a/crates/trusted-server-core/src/auction/endpoints.rs +++ b/crates/trusted-server-core/src/auction/endpoints.rs @@ -769,6 +769,7 @@ mod tests { "the endpoint must hand its snapshot to the request context" ); + ec_context.set_eid_sync_source(crate::ec::EidSyncSource::Auction); let mut response = http::Response::new(EdgeBody::empty()); crate::ec::finalize::ec_finalize_response( &settings, diff --git a/crates/trusted-server-core/src/ec/admin.rs b/crates/trusted-server-core/src/ec/admin.rs index fb8ef5192..67b13c4b6 100644 --- a/crates/trusted-server-core/src/ec/admin.rs +++ b/crates/trusted-server-core/src/ec/admin.rs @@ -599,9 +599,9 @@ pub fn handle_admin_eids_lookup( }, }; - // Mirror the ingestion path (`ingest_eid_cookies`): collect matches from - // both cookies, then dedupe the same way so the preview reports exactly - // what a navigation would store. + // Collect matches from both cookies, then dedupe the same way as response + // finalization so the preview reports exactly what an eligible request + // would store. if let Some(value) = &sharedid_cookie && let Some(update) = collect_sharedid_update(value, registry) { diff --git a/crates/trusted-server-core/src/ec/finalize.rs b/crates/trusted-server-core/src/ec/finalize.rs index 7cd0cb6e3..2c5bbde09 100644 --- a/crates/trusted-server-core/src/ec/finalize.rs +++ b/crates/trusted-server-core/src/ec/finalize.rs @@ -14,12 +14,15 @@ use crate::settings::Settings; use super::EcContext; use super::cookies::{expire_ec_cookie, set_ec_cookie}; use super::generation::{generate_ec_id, is_valid_ec_id}; -use super::kv::{CreateIfAbsentOutcome, KvIdentityGraph, apply_partner_id_updates}; +use super::kv::{ + CreateIfAbsentOutcome, EidCookieSyncOutcome, KvIdentityGraph, PartnerIdUpdate, + apply_partner_id_updates, +}; use super::kv_types::KvEntry; use super::prebid_eids::collect_eid_cookie_updates; use super::pull_sync_marker::{expire_marker, reconcile_marker}; use super::registry::PartnerRegistry; -use super::{EcKvSnapshot, current_timestamp, log_id}; +use super::{EcKvSnapshot, EidSyncSource, current_timestamp, log_id}; /// TS-managed response headers tied to EC identity output. const EC_RESPONSE_HEADERS: &[&str] = &[ @@ -74,13 +77,13 @@ pub fn ec_finalize_response( // Returning user: consent is granted and EC came from request. if ec_context.ec_was_present() && !ec_context.ec_generated() && consent_allows_ec { if let (Some(graph), Some(ec_id)) = (kv, ec_context.ec_value().map(str::to_owned)) { - let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); - let snapshot = graph.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - ec_context.kv_snapshot().clone(), - ); - ec_context.set_kv_snapshot(snapshot); + let source = ec_context.eid_sync_source(); + let updates = source + .map(|_| collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry)) + .unwrap_or_default(); + if let Some(source) = source { + sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, source); + } if matches!(ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. }) && ec_context.recovery_eligible() { @@ -108,12 +111,7 @@ pub fn ec_finalize_response( }; let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); - let snapshot = graph.upsert_partner_ids_from_snapshot( - &ec_id, - &updates, - ec_context.kv_snapshot().clone(), - ); - ec_context.set_kv_snapshot(snapshot); + sync_eid_cookie_updates(graph, ec_context, &ec_id, &updates, EidSyncSource::NewEc); if ec_context.kv_snapshot().entry_for(&ec_id).is_some() { set_ec_cookie_on_response(settings, ec_context, response); } else { @@ -124,6 +122,72 @@ pub fn ec_finalize_response( reconcile_pull_sync_marker(settings, registry, ec_context, response); } +fn sync_eid_cookie_updates( + graph: &KvIdentityGraph, + ec_context: &mut EcContext, + ec_id: &str, + updates: &[PartnerIdUpdate], + source: EidSyncSource, +) { + if updates.is_empty() { + return; + } + + let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + ec_id, + updates, + ec_context.kv_snapshot().clone(), + ); + ec_context.set_kv_snapshot(snapshot); + record_eid_sync_terminal(source, outcome); +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EidSyncMeasurement { + source: EidSyncSource, + outcome: EidCookieSyncOutcome, + already_matched: u8, + written: u8, + conflict_duplicate: u8, + deferred: u8, +} + +impl EidSyncMeasurement { + fn new(source: EidSyncSource, outcome: EidCookieSyncOutcome) -> Self { + Self { + source, + outcome, + already_matched: u8::from(matches!(outcome, EidCookieSyncOutcome::AlreadyMatched)), + written: u8::from(matches!( + outcome, + EidCookieSyncOutcome::Written | EidCookieSyncOutcome::WrittenWithDeferredFreshness + )), + conflict_duplicate: u8::from(matches!(outcome, EidCookieSyncOutcome::ConflictMatched)), + deferred: u8::from(matches!( + outcome, + EidCookieSyncOutcome::WrittenWithDeferredFreshness + | EidCookieSyncOutcome::DeferredConflict + | EidCookieSyncOutcome::DeferredFreshness + | EidCookieSyncOutcome::DeferredStaleRead + )), + } + } +} + +fn record_eid_sync_terminal(source: EidSyncSource, outcome: EidCookieSyncOutcome) { + let measurement = EidSyncMeasurement::new(source, outcome); + log::info!( + "EID sync measurement: source={} outcome={} attempted=1 already_matched={} written={} \ + conflict_duplicate={} deferred={}", + measurement.source, + measurement.outcome, + measurement.already_matched, + measurement.written, + measurement.conflict_duplicate, + measurement.deferred, + ); +} + fn reconcile_pull_sync_marker( settings: &Settings, registry: &PartnerRegistry, @@ -244,8 +308,8 @@ fn confirm_then_recover_orphaned_ec( EcKvSnapshot::Present { .. } => { // The row became visible after the origin round trip: adopt it and // merge any pending updates rather than rotating a valid identity. - let merged = graph.upsert_partner_ids_from_snapshot(ec_id, updates, confirmed); - ec_context.set_kv_snapshot(merged); + ec_context.set_kv_snapshot(confirmed); + sync_eid_cookie_updates(graph, ec_context, ec_id, updates, EidSyncSource::Navigation); } EcKvSnapshot::Missing { .. } => match graph.key_exists_confirmed(ec_id) { Ok(false) => recover_orphaned_ec(settings, ec_context, graph, updates, response), @@ -673,6 +737,7 @@ mod tests { ) .expect("should seed the identity"); ec_context.set_kv_snapshot(kv.load_snapshot(&ec_id)); + ec_context.set_eid_sync_source(EidSyncSource::Auction); assert!(matches!( ec_context.kv_snapshot(), EcKvSnapshot::Missing { .. } @@ -1015,11 +1080,232 @@ mod tests { } #[test] - fn finalize_named_route_transient_miss_still_persists_eid_updates() { - // `/auction` and `/_ts/page-bids` save their first lookup into the - // context and are never recovery eligible, so a stale miss there has no - // later chance to retry. Finalization must revalidate before dropping - // the collected partner IDs. + fn finalize_returning_user_subresource_does_not_persist_eid_updates() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("subeid"); + let graph = KvIdentityGraph::in_memory("test_store"); + let live = KvEntry::new( + &granting_consent(), + None, + current_timestamp(), + &settings.publisher.domain, + ); + graph + .create(&ec_id, &live) + .expect("should seed the live row"); + let mut ec_context = returning_user_context(&ec_id, graph.load_snapshot(&ec_id), false); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + None, + Some("shared-cookie-id"), + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should remain"); + assert!( + !stored.ids.contains_key("sharedid.org"), + "a subresource response must not persist request EID cookies" + ); + } + + #[test] + fn finalize_navigation_routes_persist_returning_user_eid_updates() { + for (source, suffix, cookie_id) in [ + (EidSyncSource::Navigation, "naveid", "navigation-cookie-id"), + (EidSyncSource::PageBids, "spaeid", "page-bids-cookie-id"), + ] { + let settings = create_test_settings(); + let ec_id = sample_ec_id(suffix); + let graph = KvIdentityGraph::in_memory("test_store"); + let live = KvEntry::new( + &granting_consent(), + None, + current_timestamp(), + &settings.publisher.domain, + ); + graph + .create(&ec_id, &live) + .expect("should seed the live row"); + let mut ec_context = returning_user_context(&ec_id, graph.load_snapshot(&ec_id), true); + ec_context.set_eid_sync_source(source); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + None, + Some(cookie_id), + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should remain"); + assert_eq!( + stored.ids.get("sharedid.org").map(|id| id.uid.as_str()), + Some(cookie_id), + "{source} should persist the returning-user EID cookie" + ); + } + } + + #[test] + fn finalize_generated_ec_persists_eid_updates() { + let settings = create_test_settings(); + let ec_id = sample_ec_id("geneid"); + let graph = KvIdentityGraph::in_memory("test_store"); + let live = KvEntry::new( + &granting_consent(), + None, + current_timestamp(), + &settings.publisher.domain, + ); + graph + .create(&ec_id, &live) + .expect("should seed generated row"); + let mut ec_context = + make_context(Some(&ec_id), None, false, true, Jurisdiction::NonRegulated); + ec_context.set_kv_snapshot(graph.load_snapshot(&ec_id)); + let partners = vec![make_partner("sharedid.org")]; + let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); + let mut response = empty_response(); + + ec_finalize_response( + &settings, + &mut ec_context, + Some(&graph), + ®istry, + None, + Some("generated-cookie-id"), + &mut response, + ); + + let (stored, _) = graph + .get(&ec_id) + .expect("should read store") + .expect("row should remain"); + assert_eq!( + stored.ids.get("sharedid.org").map(|id| id.uid.as_str()), + Some("generated-cookie-id") + ); + } + + #[test] + fn eid_sync_measurement_dimensions_are_bounded_and_identity_free() { + let sources = [ + EidSyncSource::Navigation, + EidSyncSource::Auction, + EidSyncSource::PageBids, + EidSyncSource::NewEc, + ]; + let outcomes = [ + EidCookieSyncOutcome::AlreadyMatched, + EidCookieSyncOutcome::Written, + EidCookieSyncOutcome::WrittenWithDeferredFreshness, + EidCookieSyncOutcome::ConflictMatched, + EidCookieSyncOutcome::DeferredConflict, + EidCookieSyncOutcome::DeferredFreshness, + EidCookieSyncOutcome::DeferredStaleRead, + EidCookieSyncOutcome::Missing, + EidCookieSyncOutcome::ConsentWithdrawn, + EidCookieSyncOutcome::Failed, + ]; + + assert_eq!( + sources.map(|source| source.to_string()), + ["navigation", "auction", "page_bids", "new_ec"] + ); + assert_eq!( + outcomes.map(|outcome| outcome.to_string()), + [ + "already_matched", + "written", + "written_with_deferred_freshness", + "conflict_matched", + "deferred_conflict", + "deferred_freshness", + "deferred_stale_read", + "missing", + "consent_withdrawn", + "failed", + ] + ); + + assert_eq!( + EidSyncMeasurement::new( + EidSyncSource::Navigation, + EidCookieSyncOutcome::AlreadyMatched, + ), + EidSyncMeasurement { + source: EidSyncSource::Navigation, + outcome: EidCookieSyncOutcome::AlreadyMatched, + already_matched: 1, + written: 0, + conflict_duplicate: 0, + deferred: 0, + } + ); + assert_eq!( + EidSyncMeasurement::new( + EidSyncSource::Auction, + EidCookieSyncOutcome::WrittenWithDeferredFreshness, + ), + EidSyncMeasurement { + source: EidSyncSource::Auction, + outcome: EidCookieSyncOutcome::WrittenWithDeferredFreshness, + already_matched: 0, + written: 1, + conflict_duplicate: 0, + deferred: 1, + } + ); + assert_eq!( + EidSyncMeasurement::new(EidSyncSource::NewEc, EidCookieSyncOutcome::ConflictMatched,), + EidSyncMeasurement { + source: EidSyncSource::NewEc, + outcome: EidCookieSyncOutcome::ConflictMatched, + already_matched: 0, + written: 0, + conflict_duplicate: 1, + deferred: 0, + } + ); + assert_eq!( + EidSyncMeasurement::new( + EidSyncSource::PageBids, + EidCookieSyncOutcome::DeferredStaleRead, + ), + EidSyncMeasurement { + source: EidSyncSource::PageBids, + outcome: EidCookieSyncOutcome::DeferredStaleRead, + already_matched: 0, + written: 0, + conflict_duplicate: 0, + deferred: 1, + } + ); + } + + #[test] + fn finalize_auction_transient_miss_still_persists_eid_updates() { + // `/auction` saves its first lookup into the context and is never + // recovery eligible, so a stale miss there has no later chance to + // retry. Finalization must revalidate before dropping collected IDs. let settings = create_test_settings(); let ec_id = sample_ec_id("named1"); let graph = KvIdentityGraph::in_memory("test_store"); @@ -1039,6 +1325,7 @@ mod tests { }, false, ); + ec_context.set_eid_sync_source(EidSyncSource::Auction); let partners = vec![make_partner("sharedid.org")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let mut response = empty_response(); @@ -1066,7 +1353,7 @@ mod tests { } #[test] - fn finalize_named_route_confirmed_miss_does_not_create_a_row() { + fn finalize_auction_confirmed_miss_does_not_create_a_row() { // The same path with a genuinely absent row must stay a no-op: a route // without orphan recovery must never mint an identity-graph entry. let settings = create_test_settings(); @@ -1079,6 +1366,7 @@ mod tests { }, false, ); + ec_context.set_eid_sync_source(EidSyncSource::Auction); let partners = vec![make_partner("sharedid.org")]; let registry = PartnerRegistry::from_config(&partners).expect("should build registry"); let mut response = empty_response(); diff --git a/crates/trusted-server-core/src/ec/kv.rs b/crates/trusted-server-core/src/ec/kv.rs index c7536c9ff..b0eb83d3f 100644 --- a/crates/trusted-server-core/src/ec/kv.rs +++ b/crates/trusted-server-core/src/ec/kv.rs @@ -89,6 +89,104 @@ impl PartnerIdUpdate { } } +/// Terminal result of one browser EID-cookie persistence attempt. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub(crate) enum EidCookieSyncOutcome { + /// The stored values already matched without a write. + #[display("already_matched")] + AlreadyMatched, + /// The one conditional write succeeded. + #[display("written")] + Written, + /// The write added missing IDs while deferring different values of unknown freshness. + #[display("written_with_deferred_freshness")] + WrittenWithDeferredFreshness, + /// A conflicting writer persisted every desired value. + #[display("conflict_matched")] + ConflictMatched, + /// A conflict left at least one desired value absent or different. + #[display("deferred_conflict")] + DeferredConflict, + /// A different stored value had unknown freshness. + #[display("deferred_freshness")] + DeferredFreshness, + /// An eventually consistent read missed a row already proven to exist. + #[display("deferred_stale_read")] + DeferredStaleRead, + /// The identity graph row was missing. + #[display("missing")] + Missing, + /// Consent had been withdrawn in the identity graph. + #[display("consent_withdrawn")] + ConsentWithdrawn, + /// KV or serialization failed. + #[display("failed")] + Failed, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CookieUpdateApplication { + AlreadyMatched, + Changed { deferred: bool }, + DeferredFreshness, +} + +fn apply_cookie_partner_id_updates( + entry: &mut KvEntry, + updates: &[PartnerIdUpdate], +) -> CookieUpdateApplication { + let mut latest_updates = BTreeMap::new(); + for update in updates { + latest_updates.insert(update.partner_id.as_str(), update.uid.as_str()); + } + + let mut changed = false; + let mut deferred = false; + for (partner_id, uid) in latest_updates { + match entry.ids.get(partner_id) { + Some(existing) if existing.uid == uid => continue, + // Browser EID cookies carry no value-owned sequence or timestamp. + // A different value therefore has unknown freshness and must not + // replace the stored value. + Some(_) => { + deferred = true; + continue; + } + None => {} + } + + entry.ids.insert( + partner_id.to_owned(), + super::kv_types::KvPartnerId { + uid: uid.to_owned(), + }, + ); + changed = true; + } + + if changed { + CookieUpdateApplication::Changed { deferred } + } else if deferred { + CookieUpdateApplication::DeferredFreshness + } else { + CookieUpdateApplication::AlreadyMatched + } +} + +fn partner_id_updates_match(entry: &KvEntry, updates: &[PartnerIdUpdate]) -> bool { + let mut latest_updates = BTreeMap::new(); + for update in updates { + latest_updates.insert(update.partner_id.as_str(), update.uid.as_str()); + } + + latest_updates.into_iter().all(|(partner_id, uid)| { + entry + .ids + .get(partner_id) + .is_some_and(|existing| existing.uid == uid) + }) +} + pub(crate) fn apply_partner_id_updates(entry: &mut KvEntry, updates: &[PartnerIdUpdate]) -> bool { let mut latest_updates = BTreeMap::new(); for update in updates { @@ -457,88 +555,152 @@ impl KvIdentityGraph { ))) } - /// Atomically merges multiple partner IDs into the existing entry. - /// - /// Uses one read-modify-write operation for all updates so request-local - /// EID cookie ingestion does not perform a KV read per matched partner. - /// Duplicate partner IDs are collapsed with the last value winning. + /// Persists browser EID cookies with one conditional write and one conflict read. /// - /// # Errors - /// - /// Returns [`TrustedServerError::KvStore`] on store error, missing root - /// entry, withdrawn root entry, or CAS exhaustion after - /// [`MAX_CAS_RETRIES`] attempts. - pub(crate) fn upsert_partner_ids( + /// Browser cookies do not carry a value-owned version, so a different + /// existing value has unknown freshness and is always deferred. After a CAS + /// conflict this method never writes again. A live follow-up becomes the + /// authoritative snapshot; a missing or failed follow-up retains the live + /// pre-write snapshot as proof and defers the update. + pub(crate) fn sync_eid_cookie_updates_from_snapshot( &self, ec_id: &str, updates: &[PartnerIdUpdate], - ) -> Result<(), Report> { + snapshot: EcKvSnapshot, + ) -> (EcKvSnapshot, EidCookieSyncOutcome) { if updates.is_empty() { - return Ok(()); + return (snapshot, EidCookieSyncOutcome::AlreadyMatched); } - for attempt in 0..MAX_CAS_RETRIES { - let (mut entry, generation) = match self.get(ec_id)? { - Some(pair) => pair, - None => { - log::info!( - "upsert_partner_ids: no entry for '{}', rejecting {} partner updates", - log_id(ec_id), - updates.len(), - ); - return Err(self.kv_error(format!( - "Cannot upsert {} partner IDs for missing key '{}'", - updates.len(), - log_id(ec_id), - ))); - } - }; + let proven = match snapshot { + EcKvSnapshot::Present { + ec_id: ref snapshot_id, + .. + } if snapshot_id == ec_id => Some(snapshot.clone()), + _ => None, + }; - // Reject upserts on withdrawn entries — a late sync must not - // repopulate partner IDs after consent withdrawal. - if !entry.consent.ok { - log::info!( - "upsert_partner_ids: entry for '{}' is a tombstone, rejecting {} partner updates", - log_id(ec_id), - updates.len(), + let current = match snapshot { + EcKvSnapshot::Present { + ec_id: ref snapshot_id, + generation: Some(_), + .. + } if snapshot_id == ec_id => snapshot, + EcKvSnapshot::Failed { + ec_id: ref snapshot_id, + } if snapshot_id == ec_id => return (snapshot, EidCookieSyncOutcome::Failed), + _ => self.load_snapshot(ec_id), + }; + + let (mut entry, generation) = match current { + EcKvSnapshot::Present { + ec_id: ref snapshot_id, + ref entry, + generation: Some(generation), + } if snapshot_id == ec_id => (entry.as_ref().clone(), generation), + EcKvSnapshot::Missing { .. } => { + let outcome = if proven.is_some() { + EidCookieSyncOutcome::DeferredStaleRead + } else { + EidCookieSyncOutcome::Missing + }; + let kept = Self::keep_proven(ec_id, current, proven.as_ref()); + return (kept, outcome); + } + EcKvSnapshot::Failed { .. } => { + let kept = Self::keep_proven(ec_id, current, proven.as_ref()); + return (kept, EidCookieSyncOutcome::Failed); + } + EcKvSnapshot::Present { .. } | EcKvSnapshot::NotRead => { + return ( + EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }, + EidCookieSyncOutcome::Failed, ); - return Err(self.kv_error(format!( - "Cannot upsert {} partner IDs for withdrawn key '{}'", - updates.len(), - log_id(ec_id), - ))); } + }; - if !apply_partner_id_updates(&mut entry, updates) { - return Ok(()); + if !entry.consent.ok { + return (current, EidCookieSyncOutcome::ConsentWithdrawn); + } + let deferred_freshness = match apply_cookie_partner_id_updates(&mut entry, updates) { + CookieUpdateApplication::AlreadyMatched => { + return (current, EidCookieSyncOutcome::AlreadyMatched); } + CookieUpdateApplication::DeferredFreshness => { + return (current, EidCookieSyncOutcome::DeferredFreshness); + } + CookieUpdateApplication::Changed { deferred } => deferred, + }; - let (body, meta_str) = Self::serialize_entry(&entry, self.store_name())?; - - match self.write_entry( - ec_id, - &body, - &meta_str, - ENTRY_TTL, - EcKvWriteMode::IfGenerationMatch(generation), - )? { - EcKvWriteOutcome::Written => return Ok(()), - EcKvWriteOutcome::PreconditionFailed => { - log::debug!( - "upsert_partner_ids: CAS conflict on attempt {}/{MAX_CAS_RETRIES} for '{}'", - attempt + 1, - log_id(ec_id), - ); - // Retry immediately; sleeping here blocks the edge worker. + let Ok((body, meta_str)) = Self::serialize_entry(&entry, self.store_name()) else { + return ( + EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }, + EidCookieSyncOutcome::Failed, + ); + }; + match self.write_entry( + ec_id, + &body, + &meta_str, + ENTRY_TTL, + EcKvWriteMode::IfGenerationMatch(generation), + ) { + Ok(EcKvWriteOutcome::Written) => ( + EcKvSnapshot::Present { + ec_id: ec_id.to_owned(), + entry: Box::new(entry), + generation: None, + }, + if deferred_freshness { + EidCookieSyncOutcome::WrittenWithDeferredFreshness + } else { + EidCookieSyncOutcome::Written + }, + ), + Ok(EcKvWriteOutcome::PreconditionFailed) => { + let refreshed = self.load_snapshot(ec_id); + let Some(refreshed_entry) = refreshed.entry_for(ec_id) else { + // The failed CAS proved `current`'s generation stale. Keep + // only its existence proof so later writes must reread. + let proof = match current { + EcKvSnapshot::Present { + ec_id: proven_id, + entry, + .. + } => EcKvSnapshot::Present { + ec_id: proven_id, + entry, + generation: None, + }, + other => other, + }; + let kept = Self::keep_proven(ec_id, refreshed, Some(&proof)); + return (kept, EidCookieSyncOutcome::DeferredConflict); + }; + if !refreshed_entry.consent.ok { + return (refreshed, EidCookieSyncOutcome::ConsentWithdrawn); } + let outcome = if partner_id_updates_match(refreshed_entry, updates) { + EidCookieSyncOutcome::ConflictMatched + } else { + EidCookieSyncOutcome::DeferredConflict + }; + (refreshed, outcome) + } + Err(err) => { + log::warn!("EID cookie sync write failed: {err:?}"); + ( + EcKvSnapshot::Failed { + ec_id: ec_id.to_owned(), + }, + EidCookieSyncOutcome::Failed, + ) } } - - Err(self.kv_error(format!( - "CAS conflict after {MAX_CAS_RETRIES} retries upserting {} partner IDs for '{}'", - updates.len(), - log_id(ec_id), - ))) } /// Merges partner IDs using request-scoped persisted state as the first CAS input. @@ -756,7 +918,6 @@ impl KvIdentityGraph { return Ok(()); } - // Merge the partner ID. entry.ids.insert( partner_id.to_owned(), super::kv_types::KvPartnerId { @@ -1782,6 +1943,131 @@ mod tests { } } + /// Store that replaces the row during the first EID CAS write and records + /// the request's reads and conditional writes. + struct EidConflictEcKv { + inner: InMemoryEcKv, + concurrent_entry: KvEntry, + lookups: std::sync::Arc, + conditional_writes: std::sync::Arc, + follow_up_miss: bool, + miss_next_lookup: std::sync::atomic::AtomicBool, + } + + impl EidConflictEcKv { + fn new( + concurrent_entry: KvEntry, + lookups: std::sync::Arc, + conditional_writes: std::sync::Arc, + ) -> Self { + Self { + inner: InMemoryEcKv::new("eid-conflict-store"), + concurrent_entry, + lookups, + conditional_writes, + follow_up_miss: false, + miss_next_lookup: std::sync::atomic::AtomicBool::new(false), + } + } + + fn with_follow_up_miss(mut self) -> Self { + self.follow_up_miss = true; + self + } + + fn seed_live(&self, ec_id: &str) { + let (body, meta) = + KvIdentityGraph::serialize_entry(&live_entry(), self.inner.store_name()) + .expect("should serialize initial entry"); + self.inner + .insert( + ec_id, + EcKvWrite { + body: &body, + metadata: &meta, + ttl: ENTRY_TTL, + mode: EcKvWriteMode::Add, + }, + ) + .expect("should seed initial entry"); + } + } + + impl EcKvStore for EidConflictEcKv { + fn store_name(&self) -> &str { + self.inner.store_name() + } + + fn lookup(&self, key: &str) -> Result, Report> { + self.lookups + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + if self + .miss_next_lookup + .swap(false, std::sync::atomic::Ordering::Relaxed) + { + return Ok(None); + } + self.inner.lookup(key) + } + + fn key_exists(&self, key: &str) -> Result> { + self.inner.key_exists(key) + } + + fn insert( + &self, + key: &str, + write: EcKvWrite<'_>, + ) -> Result> { + if matches!(write.mode, EcKvWriteMode::IfGenerationMatch(_)) { + self.conditional_writes + .fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let (body, meta) = KvIdentityGraph::serialize_entry( + &self.concurrent_entry, + self.inner.store_name(), + ) + .expect("should serialize concurrent entry"); + self.inner + .insert( + key, + EcKvWrite { + body: &body, + metadata: &meta, + ttl: ENTRY_TTL, + mode: EcKvWriteMode::Overwrite, + }, + ) + .expect("should write concurrent entry"); + if self.follow_up_miss { + self.miss_next_lookup + .store(true, std::sync::atomic::Ordering::Relaxed); + } + return Ok(EcKvWriteOutcome::PreconditionFailed); + } + self.inner.insert(key, write) + } + + fn list_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result, Report> { + self.inner.list_keys_with_prefix(prefix, limit) + } + + fn count_keys_with_prefix( + &self, + prefix: &str, + limit: u32, + ) -> Result> { + self.inner.count_keys_with_prefix(prefix, limit) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + self.inner.delete(key) + } + } + #[test] fn create_or_revive_retries_cas_conflict_and_succeeds() { let store = ConflictInjectingEcKv::new(2, false); @@ -1923,6 +2209,287 @@ mod tests { assert_eq!(entry.ids["ssp_x"].uid, "original"); } + #[test] + fn eid_cookie_sync_conflict_matching_value_stops_after_one_write() { + let ec_id = snapshot_ec_id(); + let mut concurrent = live_entry(); + concurrent.ids.insert( + "ssp_x".to_owned(), + crate::ec::kv_types::KvPartnerId { + uid: "desired-uid".to_owned(), + }, + ); + let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let store = EidConflictEcKv::new( + concurrent, + std::sync::Arc::clone(&lookups), + std::sync::Arc::clone(&writes), + ); + store.seed_live(&ec_id); + let graph = KvIdentityGraph::new(store); + let snapshot = graph.load_snapshot(&ec_id); + + let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + snapshot, + ); + + assert_eq!(outcome, EidCookieSyncOutcome::ConflictMatched); + assert_eq!( + snapshot + .entry_for(&ec_id) + .and_then(|entry| entry.ids.get("ssp_x")) + .map(|id| id.uid.as_str()), + Some("desired-uid"), + "the authoritative follow-up snapshot should replace stale request state" + ); + assert_eq!( + lookups.load(std::sync::atomic::Ordering::Relaxed), + 2, + "a conflict should perform exactly one follow-up read" + ); + assert_eq!( + writes.load(std::sync::atomic::Ordering::Relaxed), + 1, + "a conflict must not trigger another conditional write" + ); + } + + #[test] + fn eid_cookie_sync_keeps_live_proof_when_conflict_follow_up_misses() { + let ec_id = snapshot_ec_id(); + let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let store = EidConflictEcKv::new( + live_entry(), + std::sync::Arc::clone(&lookups), + std::sync::Arc::clone(&writes), + ) + .with_follow_up_miss(); + store.seed_live(&ec_id); + let graph = KvIdentityGraph::new(store); + + let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + EcKvSnapshot::Missing { + ec_id: ec_id.clone(), + }, + ); + + assert_eq!(outcome, EidCookieSyncOutcome::DeferredConflict); + assert!( + snapshot.entry_for(&ec_id).is_some(), + "the live pre-write row should prevent conflict deferral from entering orphan recovery" + ); + assert_eq!( + snapshot.generation_for(&ec_id), + None, + "a failed CAS must not return its rejected generation" + ); + assert_eq!( + lookups.load(std::sync::atomic::Ordering::Relaxed), + 2, + "the initial refresh and conflict follow-up should be the only reads" + ); + assert_eq!( + writes.load(std::sync::atomic::Ordering::Relaxed), + 1, + "the conflict must remain the request's only conditional write" + ); + } + + #[test] + fn eid_cookie_sync_reports_proven_refresh_miss_as_deferred() { + let ec_id = snapshot_ec_id(); + let graph = KvIdentityGraph::in_memory("empty-store"); + let proven = EcKvSnapshot::Present { + ec_id: ec_id.clone(), + entry: Box::new(live_entry()), + generation: None, + }; + + let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + proven, + ); + + assert!( + snapshot.entry_for(&ec_id).is_some(), + "the add-confirmed row should survive an eventually consistent miss" + ); + assert_eq!( + outcome, + EidCookieSyncOutcome::DeferredStaleRead, + "a row already proven to exist should report a deferred stale read" + ); + } + + #[test] + fn partner_id_conflict_match_uses_last_duplicate_value() { + let mut entry = live_entry(); + entry.ids.insert( + "ssp_x".to_owned(), + crate::ec::kv_types::KvPartnerId { + uid: "latest-uid".to_owned(), + }, + ); + let updates = [ + PartnerIdUpdate::new("ssp_x", "older-uid"), + PartnerIdUpdate::new("ssp_x", "latest-uid"), + ]; + + assert!( + partner_id_updates_match(&entry, &updates), + "conflict matching should use the same last-value-wins rule as writes" + ); + } + + #[test] + fn eid_cookie_sync_conflicting_value_defers_after_one_write() { + let ec_id = snapshot_ec_id(); + let mut concurrent = live_entry(); + concurrent.ids.insert( + "ssp_x".to_owned(), + crate::ec::kv_types::KvPartnerId { + uid: "concurrent-uid".to_owned(), + }, + ); + let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let store = EidConflictEcKv::new( + concurrent, + std::sync::Arc::clone(&lookups), + std::sync::Arc::clone(&writes), + ); + store.seed_live(&ec_id); + let graph = KvIdentityGraph::new(store); + let snapshot = graph.load_snapshot(&ec_id); + + let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "stale-uid")], + snapshot, + ); + + assert_eq!(outcome, EidCookieSyncOutcome::DeferredConflict); + assert_eq!( + lookups.load(std::sync::atomic::Ordering::Relaxed), + 2, + "a conflict should perform exactly one follow-up read" + ); + assert_eq!( + writes.load(std::sync::atomic::Ordering::Relaxed), + 1, + "a conflict must not trigger another conditional write" + ); + let (stored, _) = graph + .get(&ec_id) + .expect("should read concurrent row") + .expect("row should remain"); + assert_eq!(stored.ids["ssp_x"].uid, "concurrent-uid"); + } + + #[test] + fn eid_cookie_sync_preserves_concurrent_withdrawal_after_conflict() { + let ec_id = snapshot_ec_id(); + let lookups = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let writes = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let store = EidConflictEcKv::new( + KvEntry::tombstone(2_000), + std::sync::Arc::clone(&lookups), + std::sync::Arc::clone(&writes), + ); + store.seed_live(&ec_id); + let graph = KvIdentityGraph::new(store); + let snapshot = graph.load_snapshot(&ec_id); + + let (snapshot, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "desired-uid")], + snapshot, + ); + + assert_eq!(outcome, EidCookieSyncOutcome::ConsentWithdrawn); + assert!( + snapshot + .entry_for(&ec_id) + .is_some_and(|entry| !entry.consent.ok), + "the refreshed withdrawal tombstone must remain authoritative" + ); + assert_eq!( + lookups.load(std::sync::atomic::Ordering::Relaxed), + 2, + "a conflict should perform exactly one follow-up read" + ); + assert_eq!( + writes.load(std::sync::atomic::Ordering::Relaxed), + 1, + "a conflict must not trigger another conditional write" + ); + } + + #[test] + fn eid_cookie_sync_defers_different_values_without_value_owned_freshness() { + let ec_id = snapshot_ec_id(); + let graph = KvIdentityGraph::in_memory("freshness-store"); + let mut entry = live_entry(); + entry.ids.insert( + "ssp_x".to_owned(), + crate::ec::kv_types::KvPartnerId { + uid: "protected-uid".to_owned(), + }, + ); + graph.create(&ec_id, &entry).expect("should seed entry"); + + let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[PartnerIdUpdate::new("ssp_x", "incoming-uid")], + graph.load_snapshot(&ec_id), + ); + + assert_eq!(outcome, EidCookieSyncOutcome::DeferredFreshness); + let (stored, _) = graph + .get(&ec_id) + .expect("should read protected row") + .expect("row should remain"); + assert_eq!(stored.ids["ssp_x"].uid, "protected-uid"); + } + + #[test] + fn eid_cookie_sync_adds_missing_ids_while_deferring_different_values() { + let ec_id = snapshot_ec_id(); + let graph = KvIdentityGraph::in_memory("mixed-freshness-store"); + let mut entry = live_entry(); + entry.ids.insert( + "ssp_x".to_owned(), + crate::ec::kv_types::KvPartnerId { + uid: "protected-uid".to_owned(), + }, + ); + graph.create(&ec_id, &entry).expect("should seed entry"); + + let (_, outcome) = graph.sync_eid_cookie_updates_from_snapshot( + &ec_id, + &[ + PartnerIdUpdate::new("ssp_x", "different-uid"), + PartnerIdUpdate::new("ssp_y", "new-uid"), + ], + graph.load_snapshot(&ec_id), + ); + + assert_eq!(outcome, EidCookieSyncOutcome::WrittenWithDeferredFreshness); + let (stored, _) = graph + .get(&ec_id) + .expect("should read updated row") + .expect("row should remain"); + assert_eq!(stored.ids["ssp_x"].uid, "protected-uid"); + assert_eq!(stored.ids["ssp_y"].uid, "new-uid"); + } + #[test] fn evaluate_cluster_returns_stored_value_without_store_io() { let kv = KvIdentityGraph::failing("nonexistent_store_for_cluster_cache_test"); @@ -3962,9 +4529,9 @@ mod tests { fn a_locally_built_error_never_carries_the_whole_identifier() { // The injected-failure case above covers errors the backend produces. // These are built in this module from the identifier itself, on every - // path a request can reach: a duplicate create, single and batched - // upserts naming a key the store does not hold or has withdrawn, and - // the CAS-exhaustion terminal errors. + // path a request can reach: a duplicate create, single upserts naming a + // key the store does not hold or has withdrawn, and the CAS-exhaustion + // terminal errors. let kv = KvIdentityGraph::in_memory("test_store"); let ec_id = format!("{}.ABC123", "a".repeat(64)); kv.create(&ec_id, &live_entry()).expect("should create"); @@ -3975,12 +4542,6 @@ mod tests { let missing = kv .upsert_partner_id(&format!("{}.ZZZ999", "b".repeat(64)), "partner", "uid") .expect_err("an upsert on a missing key should be refused"); - let batched_missing = kv - .upsert_partner_ids( - &format!("{}.ZZZ999", "b".repeat(64)), - &[PartnerIdUpdate::new("partner", "uid")], - ) - .expect_err("a batched upsert on a missing key should be refused"); let withdrawn = { assert_eq!( kv.write_withdrawal_tombstone(&ec_id, drop) @@ -3991,12 +4552,9 @@ mod tests { kv.upsert_partner_id(&ec_id, "partner", "uid") .expect_err("an upsert on a withdrawn key should be refused") }; - let batched_withdrawn = kv - .upsert_partner_ids(&ec_id, &[PartnerIdUpdate::new("partner", "uid")]) - .expect_err("a batched upsert on a withdrawn key should be refused"); - // The CAS-exhaustion paths build their message the same way, and a - // store that never lets a write land is the only way to reach them. + // The remaining CAS-exhaustion paths build their message the same way, + // and a store that never lets a write land is the only way to reach them. let cas_revive = { let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); store.seed_tombstone(&ec_id); @@ -4011,13 +4569,6 @@ mod tests { .upsert_partner_id(&ec_id, "partner", "uid") .expect_err("should exhaust CAS retries") }; - let cas_batched = { - let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); - store.seed_live(&ec_id); - KvIdentityGraph::new(store) - .upsert_partner_ids(&ec_id, &[PartnerIdUpdate::new("partner", "uid")]) - .expect_err("should exhaust CAS retries") - }; let cas_if_exists = { let store = ConflictInjectingEcKv::new(MAX_CAS_RETRIES + 1, false); store.seed_live(&ec_id); @@ -4029,12 +4580,9 @@ mod tests { for (label, report) in [ ("duplicate create", duplicate), ("missing key", missing), - ("batched missing key", batched_missing), ("withdrawn key", withdrawn), - ("batched withdrawn key", batched_withdrawn), ("CAS exhaustion reviving", cas_revive), ("CAS exhaustion upserting", cas_upsert), - ("CAS exhaustion batch upserting", cas_batched), ("CAS exhaustion upserting if present", cas_if_exists), ] { let rendered = format!("{report:?}"); diff --git a/crates/trusted-server-core/src/ec/mod.rs b/crates/trusted-server-core/src/ec/mod.rs index 587c189ef..16fd016ea 100644 --- a/crates/trusted-server-core/src/ec/mod.rs +++ b/crates/trusted-server-core/src/ec/mod.rs @@ -86,6 +86,28 @@ use self::kv::{CreateIfAbsentOutcome, KvIdentityGraph}; use self::kv_types::KvEntry; use self::pull_sync_marker::{PullSyncMarkerState, validate_marker_state}; +/// Bounded request classifications that may persist browser EID cookies. +/// +/// Adapters classify publisher navigations and `POST /auction` only after +/// pre-route filters allow dispatch. The shared page-bids handler classifies an +/// admitted SPA navigation, while EC finalization classifies new identities. +/// Challenged or blocked requests remain unclassified and cannot persist EIDs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, derive_more::Display)] +pub enum EidSyncSource { + /// Publisher top-level document navigation. + #[display("navigation")] + Navigation, + /// `POST /auction` request. + #[display("auction")] + Auction, + /// Admitted `GET /_ts/page-bids` SPA navigation. + #[display("page_bids")] + PageBids, + /// Request that generated a new EC identity during finalization. + #[display("new_ec")] + NewEc, +} + /// Request-scoped view of one EC identity-graph lookup. /// /// The state distinguishes an authoritative miss from a store failure and @@ -251,6 +273,8 @@ pub struct EcContext { recovery_eligible: bool, /// Browser-carried proof of recent pull-partner completeness. pull_sync_marker: PullSyncMarkerState, + /// Allowed returning-user EID persistence source, assigned only after request filters pass. + eid_sync_source: Option, } impl EcContext { @@ -331,6 +355,7 @@ impl EcContext { kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, pull_sync_marker: PullSyncMarkerState::from_cookie(parsed.pull_sync_marker), + eid_sync_source: None, }) } @@ -514,6 +539,17 @@ impl EcContext { self.recovery_eligible = eligible; } + /// Allows returning-user EID cookie persistence for this request source. + pub fn set_eid_sync_source(&mut self, source: EidSyncSource) { + self.eid_sync_source = Some(source); + } + + /// Returns the allowed returning-user EID persistence source. + #[must_use] + pub fn eid_sync_source(&self) -> Option { + self.eid_sync_source + } + /// Returns whether orphan recovery is allowed for this request. #[must_use] pub fn recovery_eligible(&self) -> bool { @@ -607,6 +643,7 @@ impl EcContext { kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, pull_sync_marker: PullSyncMarkerState::Absent, + eid_sync_source: None, } } @@ -630,6 +667,7 @@ impl EcContext { kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, pull_sync_marker: PullSyncMarkerState::Absent, + eid_sync_source: None, } } @@ -656,6 +694,7 @@ impl EcContext { kv_snapshot: EcKvSnapshot::NotRead, recovery_eligible: false, pull_sync_marker: PullSyncMarkerState::Absent, + eid_sync_source: None, } } } diff --git a/crates/trusted-server-core/src/ec/prebid_eids.rs b/crates/trusted-server-core/src/ec/prebid_eids.rs index 114de99df..c4e95bc4e 100644 --- a/crates/trusted-server-core/src/ec/prebid_eids.rs +++ b/crates/trusted-server-core/src/ec/prebid_eids.rs @@ -9,16 +9,13 @@ //! (`{source, id, atype}` per entry). use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64}; -use error_stack::Report; use serde::Deserialize; use serde_json::Value as JsonValue; -use crate::error::TrustedServerError; use crate::openrtb::{Eid, Uid}; -use super::kv::{KvIdentityGraph, PartnerIdUpdate}; +use super::kv::PartnerIdUpdate; use super::kv_types::MAX_UID_LENGTH; -use super::log_id; use super::registry::PartnerRegistry; /// Maximum raw `ts-eids` cookie size accepted before base64 decode. @@ -65,24 +62,6 @@ pub(crate) struct PrebidEidAnalysis { pub(crate) updates: Vec, } -trait PartnerIdBulkWriter { - fn upsert_partner_ids( - &self, - ec_id: &str, - updates: &[PartnerIdUpdate], - ) -> Result<(), Report>; -} - -impl PartnerIdBulkWriter for KvIdentityGraph { - fn upsert_partner_ids( - &self, - ec_id: &str, - updates: &[PartnerIdUpdate], - ) -> Result<(), Report> { - KvIdentityGraph::upsert_partner_ids(self, ec_id, updates) - } -} - /// Parses a `ts-eids` cookie value into OpenRTB-style `Eid` entries. /// /// Accepts both the current structured cookie format and the earlier legacy @@ -147,24 +126,6 @@ impl DecodedCookieEids { } } -/// Parses request-local EID cookies and writes matched partner UIDs to KV. -/// -/// `eids_cookie` is the raw base64-encoded `ts-eids` value and -/// `sharedid_cookie` is the raw `sharedId` cookie value. Both values should -/// already be extracted from the request by the caller. -/// -/// Best-effort: all errors are logged and swallowed so the main request -/// path is never affected. -pub fn ingest_eid_cookies( - eids_cookie: Option<&str>, - sharedid_cookie: Option<&str>, - ec_id: &str, - kv: &KvIdentityGraph, - registry: &PartnerRegistry, -) { - ingest_eid_cookies_with_writer(eids_cookie, sharedid_cookie, ec_id, kv, registry); -} - /// Collects validated request-local partner updates without performing KV I/O. pub(crate) fn collect_eid_cookie_updates( eids_cookie: Option<&str>, @@ -187,52 +148,6 @@ pub(crate) fn collect_eid_cookie_updates( dedupe_partner_updates(updates) } -/// Parses a `ts-eids` cookie value and writes matched partner UIDs to KV. -/// -/// `cookie_value` is the raw base64-encoded cookie value, already extracted -/// from the request by the caller. -/// -/// Best-effort: all errors are logged and swallowed so the main request -/// path is never affected. -pub fn ingest_prebid_eids( - cookie_value: &str, - ec_id: &str, - kv: &KvIdentityGraph, - registry: &PartnerRegistry, -) { - ingest_eid_cookies(Some(cookie_value), None, ec_id, kv, registry); -} - -fn ingest_eid_cookies_with_writer( - eids_cookie: Option<&str>, - sharedid_cookie: Option<&str>, - ec_id: &str, - writer: &dyn PartnerIdBulkWriter, - registry: &PartnerRegistry, -) { - let updates = collect_eid_cookie_updates(eids_cookie, sharedid_cookie, registry); - if updates.is_empty() { - return; - } - - match writer.upsert_partner_ids(ec_id, &updates) { - Ok(()) => { - log::debug!( - "EID cookies: synced {} partner IDs for EC ID '{}'", - updates.len(), - log_id(ec_id), - ); - } - Err(err) => { - log::warn!( - "EID cookies: failed to sync {} partner IDs for EC ID '{}': {err:?}", - updates.len(), - log_id(ec_id), - ); - } - } -} - pub(crate) fn collect_prebid_eid_updates( cookie_value: &str, registry: &PartnerRegistry, @@ -313,22 +228,6 @@ pub(crate) fn is_valid_eid_uid(uid: &str) -> bool { /// `SharedID` EID source domain used for partner registry lookup. const SHAREDID_SOURCE_DOMAIN: &str = "sharedid.org"; -/// Ingests a raw `sharedId` cookie value into the KV identity graph. -/// -/// Prebid's `SharedID` module writes a `sharedId` cookie directly in the -/// browser. This function reads that value and stores it under the -/// configured `SharedID` partner. -/// -/// Best-effort: all errors are logged and swallowed. -pub fn ingest_sharedid_cookie( - cookie_value: &str, - ec_id: &str, - kv: &KvIdentityGraph, - registry: &PartnerRegistry, -) { - ingest_eid_cookies(None, Some(cookie_value), ec_id, kv, registry); -} - pub(crate) fn collect_sharedid_update( cookie_value: &str, registry: &PartnerRegistry, @@ -427,8 +326,6 @@ fn legacy_cookie_eids_to_openrtb(entries: Vec) -> Vec { #[cfg(test)] mod tests { - use std::cell::RefCell; - use super::*; use base64::engine::general_purpose::STANDARD as BASE64; use serde_json::json; @@ -437,22 +334,6 @@ mod tests { use crate::redacted::Redacted; use crate::settings::EcPartner; - #[derive(Default)] - struct RecordingWriter { - calls: RefCell>>, - } - - impl PartnerIdBulkWriter for RecordingWriter { - fn upsert_partner_ids( - &self, - _ec_id: &str, - updates: &[PartnerIdUpdate], - ) -> Result<(), Report> { - self.calls.borrow_mut().push(updates.to_vec()); - Ok(()) - } - } - fn make_test_partner(_id: &str, source_domain: &str) -> EcPartner { EcPartner { name: format!("Partner {source_domain}"), @@ -769,7 +650,7 @@ mod tests { } #[test] - fn ingest_eid_cookies_calls_writer_once_for_multiple_updates() { + fn collect_eid_cookie_updates_combines_cookie_sources() { let registry = make_registry(vec![ ("id5", "id5-sync.com"), ("liveramp", "liveramp.com"), @@ -779,29 +660,23 @@ mod tests { {"source": "id5-sync.com", "uids": [{"id": "ID5_abc", "atype": 1}]}, {"source": "liveramp.com", "uids": [{"id": "LR_xyz", "atype": 3}]} ])); - let writer = RecordingWriter::default(); - - ingest_eid_cookies_with_writer( - Some(&cookie), - Some("shared-cookie-id"), - "ec-id", - &writer, - ®istry, - ); - let calls = writer.calls.borrow(); - assert_eq!(calls.len(), 1, "should perform one bulk writer call"); - assert_eq!(calls[0].len(), 3, "should write all updates in one batch"); - assert_eq!(calls[0][0], PartnerIdUpdate::new("id5-sync.com", "ID5_abc")); - assert_eq!(calls[0][1], PartnerIdUpdate::new("liveramp.com", "LR_xyz")); + let updates = + collect_eid_cookie_updates(Some(&cookie), Some("shared-cookie-id"), ®istry); + assert_eq!( - calls[0][2], - PartnerIdUpdate::new("sharedid.org", "shared-cookie-id") + updates, + vec![ + PartnerIdUpdate::new("id5-sync.com", "ID5_abc"), + PartnerIdUpdate::new("liveramp.com", "LR_xyz"), + PartnerIdUpdate::new("sharedid.org", "shared-cookie-id"), + ], + "should collect every matched update in one batch" ); } #[test] - fn ingest_liveramp_eid_cookie_preserves_the_opaque_envelope() { + fn collect_liveramp_eid_cookie_updates_preserves_the_opaque_envelope() { let registry = make_registry(vec![("liveramp", "liveramp.com")]); let cookie = encode_json(&json!([ { @@ -809,58 +684,44 @@ mod tests { "uids": [{"id": "opaque-test-envelope", "atype": 3}] } ])); - let writer = RecordingWriter::default(); - ingest_eid_cookies_with_writer(Some(&cookie), None, "ec-id", &writer, ®istry); + let updates = collect_eid_cookie_updates(Some(&cookie), None, ®istry); - let calls = writer.calls.borrow(); - assert_eq!(calls.len(), 1, "should perform one bulk writer call"); - assert_eq!(calls[0].len(), 1, "should write one LiveRamp partner ID"); assert_eq!( - calls[0][0], - PartnerIdUpdate::new("liveramp.com", "opaque-test-envelope"), + updates, + vec![PartnerIdUpdate::new("liveramp.com", "opaque-test-envelope")], "should preserve the opaque envelope without decoding it" ); } #[test] - fn ingest_eid_cookies_sharedid_cookie_overrides_prebid_sharedid_update() { + fn collect_eid_cookie_updates_prefers_direct_sharedid_cookie() { let registry = make_registry(vec![("sharedid", "sharedid.org")]); let cookie = encode_json(&json!([ {"source": "sharedid.org", "uids": [{"id": "prebid-shared", "atype": 3}]} ])); - let writer = RecordingWriter::default(); - - ingest_eid_cookies_with_writer( - Some(&cookie), - Some("cookie-shared"), - "ec-id", - &writer, - ®istry, - ); - let calls = writer.calls.borrow(); - assert_eq!(calls.len(), 1, "should perform one bulk writer call"); + let updates = collect_eid_cookie_updates(Some(&cookie), Some("cookie-shared"), ®istry); + assert_eq!( - calls[0], + updates, vec![PartnerIdUpdate::new("sharedid.org", "cookie-shared")], - "should apply sharedId cookie after Prebid EIDs for duplicate source domains" + "should apply the direct sharedId cookie after Prebid EIDs" ); } #[test] - fn ingest_eid_cookies_skips_writer_when_no_valid_updates() { + fn collect_eid_cookie_updates_ignores_unknown_sources() { let registry = make_registry(vec![("id5", "id5-sync.com")]); let cookie = encode_json(&json!([ {"source": "unknown.example", "uids": [{"id": "unknown", "atype": 1}]} ])); - let writer = RecordingWriter::default(); - ingest_eid_cookies_with_writer(Some(&cookie), None, "ec-id", &writer, ®istry); + let updates = collect_eid_cookie_updates(Some(&cookie), None, ®istry); assert!( - writer.calls.borrow().is_empty(), - "should not touch KV writer without valid partner updates" + updates.is_empty(), + "should ignore unmatched partner updates" ); } } diff --git a/crates/trusted-server-core/src/publisher.rs b/crates/trusted-server-core/src/publisher.rs index 12b4f3702..6f89edcd0 100644 --- a/crates/trusted-server-core/src/publisher.rs +++ b/crates/trusted-server-core/src/publisher.rs @@ -62,9 +62,9 @@ use crate::creative_opportunities::{ AdStackGateInput, AssemblyMode, CreativeOpportunitiesConfig, RuntimeAdStackExpected, evaluate_ad_stack_gate, }; -use crate::ec::EcContext; use crate::ec::kv::KvIdentityGraph; use crate::ec::registry::PartnerRegistry; +use crate::ec::{EcContext, EidSyncSource}; use crate::error::TrustedServerError; use crate::html_processor::BodyCloseInjection; use crate::http_util::{RequestInfo, is_navigation_request, serve_static_with_etag}; @@ -6714,6 +6714,7 @@ pub async fn handle_page_bids( ); return Ok(page_bids_preflight_denied()); } + ec_context.set_eid_sync_source(EidSyncSource::PageBids); // Deprecation signal for the transition alias. Evaluated after the // cross-site gate, so the count reflects genuine SPA clients still running a diff --git a/docs/guide/edge-cookies.md b/docs/guide/edge-cookies.md index 2eb54ae54..2d83a9fe1 100644 --- a/docs/guide/edge-cookies.md +++ b/docs/guide/edge-cookies.md @@ -165,16 +165,22 @@ sequenceDiagram TSJS->>TSJS: Base64 encode full OpenRTB-style EID array
[{source, uids:[{id, atype, ext?}]}] TSJS->>B: document.cookie = "ts-eids=..." - Note over B,TS: Next page request - B->>TS: Request with ts-eids cookie + Note over B,TS: Next eligible EID-sync request + B->>TS: Document navigation, POST /auction,
or admitted GET /_ts/page-bids TS->>TS: Base64 decode → parse OpenRTB-style EIDs
match source domains to partners - TS->>KV: upsert_partner_id() per match
(skips write when UID unchanged) + TS->>KV: Add missing partner IDs in one conditional write
skip when UIDs already match ``` Current TSJS writers preserve the full OpenRTB-style `{source, uids:[...]}` shape in `ts-eids`. The server remains backward-compatible with earlier flattened `{source, id, atype}` cookies during rollout, but new cookies use the structured `uids[]` form. The `sharedId` cookie follows a similar path but is written directly by Prebid's SharedID module rather than by TSJS. The server reads it separately and maps it via the `sharedid.org` source domain. +Returning-user cookie persistence runs only on publisher document navigations, `POST /auction`, and admitted `GET /_ts/page-bids` SPA navigations. Static assets, analytics, integration requests, filter short circuits, and other subresources do not decode or persist these cookies. New EC creation remains eligible regardless of route because its backing row must include the request's initial IDs. + +Each eligible request attempts at most one conditional cookie update. If another writer wins, Trusted Server reads the row once and does not write again from that request. A matching value completes the sync; an absent or different value is deferred. + +Browser cookies do not carry a trustworthy value timestamp or sequence. Trusted Server therefore adds missing partner IDs but does not replace a different stored UID from a browser cookie, even on later eligible requests. Pull- or push-enabled partners can replace that value through their authoritative synchronization path. A cookie-only partner retains the stored UID until an authoritative freshness rule is introduced or the EC row expires. + ### EID Seeding and Prebid Bidstream Forwarding EIDs can reach the EC identity graph from either server-side pull sync or browser-side Prebid sync. During a Prebid-routed auction, Trusted Server combines those stored IDs with any same-request EIDs from Prebid.js, applies consent gating, and forwards the merged set to Prebid Server as OpenRTB `user.ext.eids`. Prebid Server then passes those EIDs downstream to demand partners in its OpenRTB requests. @@ -196,8 +202,8 @@ sequenceDiagram B->>B: Prebid User ID modules resolve IDs B->>TSJS: getUserIdsAsEids() TSJS->>B: Write ts-eids cookie
Base64 OpenRTB-style EIDs - B->>TS: Next request with ts-eids - TS->>KV: Decode cookie and upsert matched partner UIDs + B->>TS: Next eligible request with ts-eids + TS->>KV: Add missing matched partner UIDs
defer different stored values end Note over B,TS: Prebid-routed auction diff --git a/docs/superpowers/specs/2026-07-10-kv-eid-request-snapshot-ec-recovery-design.md b/docs/superpowers/specs/2026-07-10-kv-eid-request-snapshot-ec-recovery-design.md index e12c75fc5..27de7bdfb 100644 --- a/docs/superpowers/specs/2026-07-10-kv-eid-request-snapshot-ec-recovery-design.md +++ b/docs/superpowers/specs/2026-07-10-kv-eid-request-snapshot-ec-recovery-design.md @@ -174,10 +174,27 @@ empty identity payload. ## Finalization Contract EC finalization accepts the request snapshot and returns an outcome containing -the current EC context and updated snapshot. Returning-user EID ingestion uses -the carried entry and generation for its first CAS attempt. It rereads only on -CAS conflict. After a successful write, it returns the updated entry with no -usable generation because the backend does not expose the new token. +the current EC context and updated snapshot. Partner-owned snapshot upserts use +the carried entry and generation for their first CAS attempt and preserve the +bounded re-merge behavior described below. + +Browser EID-cookie synchronization was later narrowed by +[#993](https://github.com/IABTechLab/trusted-server/issues/993). Returning-user +persistence runs only for publisher document navigations, `POST /auction`, and +admitted `GET /_ts/page-bids` SPA navigations. New EC creation also persists +initial browser IDs. Other subresources neither decode nor persist the cookies. + +An eligible browser-cookie sync makes at most one conditional write. A CAS +conflict performs one follow-up read and never writes again from that request. +A live follow-up replaces the request snapshot. A missing or failed follow-up +keeps prior proof of the row without retaining the rejected generation. A +matching concurrent value completes the sync; any other update is deferred. + +Browser cookies have no trustworthy value timestamp or sequence. A different +stored UID is therefore preserved on every browser-cookie sync, not only after +a conflict. Later browser requests do not converge that value by themselves; +only an authoritative partner pull or push may replace it until a freshness +rule is defined. The updated in-memory entry must include partner IDs written during finalization so post-send pull sync does not dispatch a partner that was just populated. @@ -186,9 +203,9 @@ and entry-point layers. Mutation outcomes contain only state known to be persisted. An unchanged merge returns the original entry and generation. A successful write returns the -persisted updated entry with no generation. A store error or exhausted CAS -retry returns a failed snapshot rather than claiming request-local updates were -stored. Pull sync does not dispatch from failed state. +persisted updated entry with no generation. A store error returns a failed +snapshot rather than claiming request-local updates were stored. Pull sync does +not dispatch from failed state. ## Pull Sync @@ -228,7 +245,8 @@ implementations so future adapters cannot accidentally regress auction timing. - No unverified incoming EC ID can create a KV root. - A cookie is emitted only after its backing row exists. - Tombstones are never converted to live entries by enrichment. -- CAS conflicts re-merge rather than overwrite concurrent data. +- Partner-owned CAS conflicts re-merge rather than overwrite concurrent data. +- Browser-cookie CAS conflicts perform one follow-up read and never retry the write. - Consent-denied requests expose no EC or EID data. - Post-send failures never change the client response. - Logs use redacted EC identifiers through the existing `log_id` helper.