diff --git a/pyproject.toml b/pyproject.toml index 8b26c4ab4a2..39c79de0ad0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "sentry-arroyo>=2.39.2", "sentry-conventions>=0.13.0", "sentry-kafka-schemas>=2.1.36", + "sentry-options>=1.1.1", "sentry-protos>=0.34.0", "sentry-redis-tools>=0.5.1", "sentry-relay>=0.9.25", diff --git a/rust_snuba/src/processors/eap_items.rs b/rust_snuba/src/processors/eap_items.rs index 768a697084f..c63c09bb3db 100644 --- a/rust_snuba/src/processors/eap_items.rs +++ b/rust_snuba/src/processors/eap_items.rs @@ -10,6 +10,7 @@ use uuid::Uuid; use sentry_arroyo::backends::kafka::types::KafkaPayload; use sentry_arroyo::counter; +use sentry_options::options; use sentry_protos::snuba::v1::any_value::Value; use sentry_protos::snuba::v1::{ArrayValue, TraceItem, TraceItemType}; @@ -18,7 +19,6 @@ use crate::processors::utils::{ enforce_retention, get_drop_invalid_timestamps_enabled, out_of_valid_interval_secs, record_invalid_timestamp_metric, SilencedDLQMessage, }; -use crate::runtime_config::get_str_config; use crate::strategies::clickhouse::rowbinary; use crate::types::CogsData; use crate::types::{item_type_name, InsertBatch, ItemTypeMetrics, KafkaMessageMetadata}; @@ -153,10 +153,10 @@ fn get_dlq_grace_period_min(storage_name: &str) -> Option { if storage_name.is_empty() { return None; } - get_str_config(&format!("{DLQ_GRACE_PERIOD_MIN_KEY}:{storage_name}")) + options("snuba") .ok() - .flatten() - .and_then(|s| s.parse::().ok()) + .and_then(|o| o.get(DLQ_GRACE_PERIOD_MIN_KEY).ok()) + .and_then(|v| v.get(storage_name).and_then(|n| n.as_i64())) .filter(|&n| n >= 0) } @@ -662,12 +662,21 @@ mod tests { use std::time::SystemTime; use prost_types::Timestamp; + use sentry_options::init_with_schemas; + use sentry_options::testing::override_options; use sentry_protos::snuba::v1::any_value::Value; use sentry_protos::snuba::v1::{AnyValue, ArrayValue, TraceItemType}; use serde::Deserialize; + use serde_json::json; + use std::sync::Once; use super::*; + static INIT: Once = Once::new(); + fn init_options() { + INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); + } + fn generate_trace_item(item_id: Uuid) -> TraceItem { TraceItem { attributes: Default::default(), @@ -1121,10 +1130,38 @@ mod tests { #[test] fn test_get_dlq_grace_period_min_unset_storage_returns_none() { - // Empty storage_name short-circuits to None without hitting Python. + // Empty storage_name short-circuits to None without reading options. assert_eq!(get_dlq_grace_period_min(""), None); } + #[test] + fn test_get_dlq_grace_period_min_reads_dict_option() { + init_options(); + { + let _guard = override_options(&[( + "snuba", + "eap_items_dlq_grace_period_min", + json!({ "eap_items_dlq_test": 45 }), + )]) + .unwrap(); + // Reads the per-storage entry out of the dict option (the nested get + // on the serde_json::Value returned by options(...).get(...)). + assert_eq!(get_dlq_grace_period_min("eap_items_dlq_test"), Some(45)); + // A storage with no entry in the dict falls back to None. + assert_eq!(get_dlq_grace_period_min("eap_items_dlq_other"), None); + } + { + // Negative values are rejected. + let _guard = override_options(&[( + "snuba", + "eap_items_dlq_grace_period_min", + json!({ "eap_items_dlq_test": -1 }), + )]) + .unwrap(); + assert_eq!(get_dlq_grace_period_min("eap_items_dlq_test"), None); + } + } + #[test] fn test_row_binary_basic_processing() { let item_id = Uuid::new_v4(); diff --git a/rust_snuba/src/processors/generic_metrics.rs b/rust_snuba/src/processors/generic_metrics.rs index 8fcac903d2c..8f4de883cab 100644 --- a/rust_snuba/src/processors/generic_metrics.rs +++ b/rust_snuba/src/processors/generic_metrics.rs @@ -1,6 +1,7 @@ use adler::Adler32; -use anyhow::{anyhow, Context, Error}; +use anyhow::{anyhow, Context}; use chrono::DateTime; +use sentry_options::options; use serde::{ de::value::{MapAccessDeserializer, SeqAccessDeserializer}, Deserialize, Deserializer, Serialize, @@ -8,7 +9,6 @@ use serde::{ use std::{collections::BTreeMap, marker::PhantomData, vec}; use crate::{ - runtime_config::get_str_config, types::{CogsData, InsertBatch, RowData}, KafkaMessageMetadata, ProcessorConfig, }; @@ -339,8 +339,8 @@ impl Parse for CountersRawRow { } } -fn should_use_killswitch(config: Result, Error>, use_case: &MessageUseCase) -> bool { - if let Some(killswitch) = config.ok().flatten() { +fn should_use_killswitch(config: Option, use_case: &MessageUseCase) -> bool { + if let Some(killswitch) = config { return killswitch.contains(use_case.use_case_id.as_str()); } @@ -355,7 +355,10 @@ where T: Parse + Serialize, { let payload_bytes = payload.payload().context("Expected payload")?; - let killswitch_config = get_str_config("generic_metrics_use_case_killswitch"); + let killswitch_config = options("snuba") + .ok() + .and_then(|o| o.get("generic_metrics_use_case_killswitch").ok()) + .and_then(|v| v.as_str().map(String::from)); let use_case: MessageUseCase = serde_json::from_slice(payload_bytes)?; if should_use_killswitch(killswitch_config, &use_case) { @@ -1358,7 +1361,7 @@ mod tests { #[test] fn test_shouldnt_killswitch() { - let fake_config = Ok(Some("[custom]".to_string())); + let fake_config = Some("[custom]".to_string()); let use_case = MessageUseCase { use_case_id: "transactions".to_string(), }; @@ -1371,7 +1374,7 @@ mod tests { let use_case = MessageUseCase { use_case_id: "transactions".to_string(), }; - let fake_config = Ok(Some("[transactions]".to_string())); + let fake_config = Some("[transactions]".to_string()); assert!(should_use_killswitch(fake_config, &use_case)); } @@ -1381,7 +1384,7 @@ mod tests { let use_case = MessageUseCase { use_case_id: "transactions".to_string(), }; - let fake_config = Ok(Some("[transactions, custom]".to_string())); + let fake_config = Some("[transactions, custom]".to_string()); assert!(should_use_killswitch(fake_config, &use_case)); } @@ -1391,7 +1394,7 @@ mod tests { let use_case = MessageUseCase { use_case_id: "transactions".to_string(), }; - let fake_config = Ok(Some("[]".to_string())); + let fake_config = Some("[]".to_string()); assert!(!should_use_killswitch(fake_config, &use_case)); } @@ -1401,7 +1404,7 @@ mod tests { let use_case = MessageUseCase { use_case_id: "transactions".to_string(), }; - let fake_config = Ok(Some("".to_string())); + let fake_config = Some("".to_string()); assert!(!should_use_killswitch(fake_config, &use_case)); } @@ -1411,7 +1414,7 @@ mod tests { let use_case = MessageUseCase { use_case_id: "transactions".to_string(), }; - let fake_config = Ok(None); + let fake_config: Option = None; assert!(!should_use_killswitch(fake_config, &use_case)); } diff --git a/rust_snuba/src/processors/utils.rs b/rust_snuba/src/processors/utils.rs index 1b19356d4c9..531abc3de1d 100644 --- a/rust_snuba/src/processors/utils.rs +++ b/rust_snuba/src/processors/utils.rs @@ -1,9 +1,9 @@ use crate::config::EnvConfig; -use crate::runtime_config::get_str_config; use crate::types::item_type_name; use chrono::{DateTime, NaiveDateTime, Utc}; use schemars::JsonSchema; use sentry_arroyo::counter; +use sentry_options::options; use sentry_protos::snuba::v1::TraceItemType; use serde::{Deserialize, Deserializer, Serialize}; @@ -16,7 +16,7 @@ pub const INVALID_TIMESTAMP_FUTURE_INTERVAL_SECONDS: i64 = 7 * 24 * 60 * 60; /// timestamp dropping is enabled. pub const INVALID_TIMESTAMP_PAST_INTERVAL_SECONDS: i64 = 30 * 24 * 60 * 60; -/// Runtime config key. When set to `"1"`, the eap-items consumer skips messages +/// sentry-options key. When `true`, the eap-items consumer skips messages /// whose event `timestamp` is more than one week in the future or more than /// thirty days in the past (see `out_of_valid_interval_secs`). pub const DROP_INVALID_TIMESTAMPS_KEY: &str = "eap_items_drop_invalid_timestamps"; @@ -34,10 +34,10 @@ pub fn out_of_valid_interval_secs(ts: DateTime, now: DateTime) -> bool } pub fn get_drop_invalid_timestamps_enabled() -> bool { - get_str_config(DROP_INVALID_TIMESTAMPS_KEY) + options("snuba") .ok() - .flatten() - .map(|s| s == "1") + .and_then(|o| o.get(DROP_INVALID_TIMESTAMPS_KEY).ok()) + .and_then(|v| v.as_bool()) .unwrap_or(false) } diff --git a/rust_snuba/src/rebalancing.rs b/rust_snuba/src/rebalancing.rs index 9be9d840f6a..7bc7f858c2a 100644 --- a/rust_snuba/src/rebalancing.rs +++ b/rust_snuba/src/rebalancing.rs @@ -1,4 +1,4 @@ -use crate::runtime_config; +use sentry_options::options; use std::thread; use std::time::{Duration, SystemTime, UNIX_EPOCH}; @@ -29,45 +29,46 @@ pub fn delay_kafka_rebalance(configured_delay_secs: u64) { } pub fn get_rebalance_delay_secs(consumer_group: &str) -> Option { - runtime_config::get_str_config( - format!("quantized_rebalance_consumer_group_delay_secs__{consumer_group}").as_str(), - ) - .ok()?? - .parse() - .ok() + // Migrated from the per-group runtime config key + // `quantized_rebalance_consumer_group_delay_secs__` to a + // single `snuba` sentry-options dict keyed by consumer group. A group with + // no entry (or a non-integer value) yields no delay. + options("snuba") + .ok() + .and_then(|o| o.get("quantized_rebalance_consumer_group_delay_secs").ok()) + .and_then(|v| v.get(consumer_group).and_then(|n| n.as_u64())) } #[cfg(test)] mod tests { use super::*; + use sentry_options::init_with_schemas; + use sentry_options::testing::override_options; + use serde_json::json; + use std::sync::Once; + + static INIT: Once = Once::new(); + + fn init_options() { + INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); + } #[test] fn test_delay_config() { - // teardown, even when the test fails - let _guard = scopeguard::guard((), |_| { - runtime_config::patch_str_config_for_test( - "quantized_rebalance_consumer_group_delay_secs__spans", - None, - ); - }); + init_options(); + + // A consumer group with no entry in the dict yields no delay. + assert_eq!(get_rebalance_delay_secs("spans"), None); + + let _guard = override_options(&[( + "snuba", + "quantized_rebalance_consumer_group_delay_secs", + json!({ "spans": 420 }), + )]) + .unwrap(); - runtime_config::patch_str_config_for_test( - "quantized_rebalance_consumer_group_delay_secs__spans", - None, - ); - let delay_secs = get_rebalance_delay_secs("spans"); - assert_eq!(delay_secs, None); - runtime_config::patch_str_config_for_test( - "quantized_rebalance_consumer_group_delay_secs__spans", - Some("420"), - ); - let delay_secs = get_rebalance_delay_secs("spans"); - assert_eq!(delay_secs, Some(420)); - runtime_config::patch_str_config_for_test( - "quantized_rebalance_consumer_group_delay_secs__spans", - Some("garbage"), - ); - let delay_secs = get_rebalance_delay_secs("spans"); - assert_eq!(delay_secs, None); + // The configured group reads its delay; an unconfigured one stays None. + assert_eq!(get_rebalance_delay_secs("spans"), Some(420)); + assert_eq!(get_rebalance_delay_secs("transactions"), None); } } diff --git a/rust_snuba/src/runtime_config.rs b/rust_snuba/src/runtime_config.rs index d5d7b68472d..4344a877dba 100644 --- a/rust_snuba/src/runtime_config.rs +++ b/rust_snuba/src/runtime_config.rs @@ -1,52 +1,4 @@ -use anyhow::Error; -use parking_lot::RwLock; -use pyo3::prelude::{PyModule, Python}; -use pyo3::types::PyAnyMethods; -use std::collections::BTreeMap; -use std::time::Duration; - -use sentry_arroyo::timer; -use sentry_arroyo::utils::timing::Deadline; - -static CONFIG: RwLock, Deadline)>> = RwLock::new(BTreeMap::new()); - -#[cfg(test)] -pub fn patch_str_config_for_test(key: &str, value: Option<&str>) { - let deadline = Deadline::new(Duration::from_secs(10)); - - CONFIG - .write() - .insert(key.to_string(), (value.map(str::to_string), deadline)); -} - -/// Runtime config is cached for 10 seconds -pub fn get_str_config(key: &str) -> Result, Error> { - let deadline = Deadline::new(Duration::from_secs(10)); - - if let Some(value) = CONFIG.read().get(key) { - let (config, deadline) = value; - if !deadline.has_elapsed() { - return Ok(config.clone()); - } - } - - let rv = Python::with_gil(|py| { - let snuba_state = PyModule::import(py, "snuba.state")?; - let config = snuba_state - .getattr("get_str_config")? - .call1((key,))? - .extract::>()?; - - CONFIG - .write() - .insert(key.to_string(), (config.clone(), deadline)); - Ok(CONFIG.read().get(key).unwrap().0.clone()) - }); - - timer!("runtime_config.get_str_config", deadline.elapsed()); - - rv -} +use sentry_options::options; pub struct LoadBalancingConfig { pub load_balancing: String, @@ -54,16 +6,29 @@ pub struct LoadBalancingConfig { } pub fn get_load_balancing_config(storage_name: &str) -> LoadBalancingConfig { - let load_balancing = get_str_config(&format!("clickhouse_load_balancing:{storage_name}")) - .ok() - .flatten() + // Both keys live in the `snuba` sentry-options namespace as dicts keyed by + // storage name (migrated from the per-storage runtime config keys + // `clickhouse_load_balancing[_first_offset]:`). + let snuba_options = options("snuba").ok(); + + let load_balancing = snuba_options + .as_ref() + .and_then(|o| o.get("clickhouse_load_balancing").ok()) + .and_then(|v| { + v.get(storage_name) + .and_then(|s| s.as_str()) + .map(String::from) + }) .unwrap_or_else(|| "in_order".to_string()); - let first_offset = get_str_config(&format!( - "clickhouse_load_balancing_first_offset:{storage_name}" - )) - .ok() - .flatten(); + let first_offset = snuba_options + .as_ref() + .and_then(|o| o.get("clickhouse_load_balancing_first_offset").ok()) + .and_then(|v| { + v.get(storage_name) + .and_then(|s| s.as_str()) + .map(String::from) + }); LoadBalancingConfig { load_balancing, @@ -82,27 +47,30 @@ pub const CLICKHOUSE_DEFAULT_MAX_INSERT_BLOCK_SIZE: u64 = 1_048_449; /// past what ClickHouse already does by default. Callers should append /// `&max_insert_block_size=` to the INSERT URL when Some. pub fn get_max_insert_block_size(storage_name: &str) -> Option { - get_str_config(&format!("clickhouse_max_insert_block_size:{storage_name}")) + options("snuba") .ok() - .flatten() - .and_then(|s| s.parse::().ok()) + .and_then(|o| o.get("clickhouse_max_insert_block_size").ok()) + .and_then(|v| v.get(storage_name).and_then(|n| n.as_u64())) .filter(|&n| n >= CLICKHOUSE_DEFAULT_MAX_INSERT_BLOCK_SIZE) } #[cfg(test)] mod tests { use super::*; + use sentry_options::init_with_schemas; + use sentry_options::testing::override_options; + use serde_json::json; + use std::sync::Once; - #[test] - fn test_runtime_config() { - crate::testutils::initialize_python(); - let config = get_str_config("test"); - assert_eq!(config.unwrap(), None); + static INIT: Once = Once::new(); + + fn init_options() { + INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); } #[test] fn test_load_balancing_config_defaults() { - crate::testutils::initialize_python(); + init_options(); let config = get_load_balancing_config("lb_defaults_test"); assert_eq!(config.load_balancing, "in_order"); assert_eq!(config.first_offset, None); @@ -110,15 +78,20 @@ mod tests { #[test] fn test_load_balancing_config_overrides() { - crate::testutils::initialize_python(); - patch_str_config_for_test( - "clickhouse_load_balancing:lb_overrides_test", - Some("first_or_random"), - ); - patch_str_config_for_test( - "clickhouse_load_balancing_first_offset:lb_overrides_test", - Some("1"), - ); + init_options(); + let _guard = override_options(&[ + ( + "snuba", + "clickhouse_load_balancing", + json!({ "lb_overrides_test": "first_or_random" }), + ), + ( + "snuba", + "clickhouse_load_balancing_first_offset", + json!({ "lb_overrides_test": "1" }), + ), + ]) + .unwrap(); let config = get_load_balancing_config("lb_overrides_test"); assert_eq!(config.load_balancing, "first_or_random"); diff --git a/rust_snuba/src/strategies/clickhouse/writer_v2.rs b/rust_snuba/src/strategies/clickhouse/writer_v2.rs index 04c7ae65dd1..f684063ca0f 100644 --- a/rust_snuba/src/strategies/clickhouse/writer_v2.rs +++ b/rust_snuba/src/strategies/clickhouse/writer_v2.rs @@ -414,8 +414,17 @@ fn lz4_compress(input: &[u8]) -> Vec { #[cfg(test)] mod tests { use super::*; + use sentry_options::init_with_schemas; + use sentry_options::testing::override_options; + use serde_json::json; + use std::sync::Once; use tokio::time::Instant; + static INIT: Once = Once::new(); + fn init_options() { + INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); + } + fn make_test_config() -> ClickhouseConfig { ClickhouseConfig { host: std::env::var("CLICKHOUSE_HOST").unwrap_or("127.0.0.1".to_string()), @@ -463,6 +472,7 @@ mod tests { #[test] fn test_url_with_runtime_config_override() { crate::testutils::initialize_python(); + init_options(); let config = make_test_config(); let client = ClickhouseClient::new( &config, @@ -478,14 +488,19 @@ mod tests { assert!(!url.contains("load_balancing_first_offset")); // Override to first_or_random with offset - crate::runtime_config::patch_str_config_for_test( - "clickhouse_load_balancing:writer_v2_lb_test", - Some("first_or_random"), - ); - crate::runtime_config::patch_str_config_for_test( - "clickhouse_load_balancing_first_offset:writer_v2_lb_test", - Some("1"), - ); + let _guard = override_options(&[ + ( + "snuba", + "clickhouse_load_balancing", + json!({ "writer_v2_lb_test": "first_or_random" }), + ), + ( + "snuba", + "clickhouse_load_balancing_first_offset", + json!({ "writer_v2_lb_test": "1" }), + ), + ]) + .unwrap(); let url = client.build_url(); assert!(url.contains("load_balancing=first_or_random")); @@ -495,6 +510,7 @@ mod tests { #[test] fn test_url_with_max_insert_block_size() { crate::testutils::initialize_python(); + init_options(); let config = make_test_config(); let client = ClickhouseClient::new( &config, @@ -503,20 +519,6 @@ mod tests { InsertFormat::JsonEachRow, None, ); - - // Default (key absent): no suffix. - let url = client.build_url(); - assert!(!url.contains("max_insert_block_size")); - - // Per-storage override at or above the ClickHouse default sets the suffix. - crate::runtime_config::patch_str_config_for_test( - "clickhouse_max_insert_block_size:writer_v2_block_size_test", - Some("2000000"), - ); - let url = client.build_url(); - assert!(url.contains("&max_insert_block_size=2000000")); - - // A different storage isn't affected. let other_client = ClickhouseClient::new( &config, "test_table", @@ -524,24 +526,48 @@ mod tests { InsertFormat::JsonEachRow, None, ); - let url = other_client.build_url(); - assert!(!url.contains("max_insert_block_size")); + + // Default (key absent): no suffix. + assert!(!client.build_url().contains("max_insert_block_size")); + + // Per-storage override at or above the ClickHouse default sets the suffix. + { + let _guard = override_options(&[( + "snuba", + "clickhouse_max_insert_block_size", + json!({ "writer_v2_block_size_test": 2_000_000 }), + )]) + .unwrap(); + assert!(client + .build_url() + .contains("&max_insert_block_size=2000000")); + // A different storage isn't affected. + assert!(!other_client.build_url().contains("max_insert_block_size")); + } // Values below the ClickHouse default (1_048_449) are rejected. - crate::runtime_config::patch_str_config_for_test( - "clickhouse_max_insert_block_size:writer_v2_block_size_test", - Some("1000000"), - ); - let url = client.build_url(); - assert!(!url.contains("max_insert_block_size")); + { + let _guard = override_options(&[( + "snuba", + "clickhouse_max_insert_block_size", + json!({ "writer_v2_block_size_test": 1_000_000 }), + )]) + .unwrap(); + assert!(!client.build_url().contains("max_insert_block_size")); + } // Exactly the default is accepted. - crate::runtime_config::patch_str_config_for_test( - "clickhouse_max_insert_block_size:writer_v2_block_size_test", - Some("1048449"), - ); - let url = client.build_url(); - assert!(url.contains("&max_insert_block_size=1048449")); + { + let _guard = override_options(&[( + "snuba", + "clickhouse_max_insert_block_size", + json!({ "writer_v2_block_size_test": 1_048_449 }), + )]) + .unwrap(); + assert!(client + .build_url() + .contains("&max_insert_block_size=1048449")); + } } /// Walks a buffer of concatenated ClickHouse-native compressed blocks, diff --git a/rust_snuba/src/strategies/healthcheck.rs b/rust_snuba/src/strategies/healthcheck.rs index 9245db18a4c..d7fdb9e9d8e 100644 --- a/rust_snuba/src/strategies/healthcheck.rs +++ b/rust_snuba/src/strategies/healthcheck.rs @@ -7,7 +7,7 @@ use sentry_arroyo::processing::strategies::{ }; use sentry_arroyo::types::Message; -use crate::runtime_config::get_str_config; +use sentry_options::options; const TOUCH_INTERVAL: Duration = Duration::from_secs(1); @@ -57,11 +57,11 @@ where fn poll(&mut self) -> Result, StrategyError> { let poll_result = self.next_step.poll(); - if get_str_config("experimental_healthcheck") + if options("snuba") .ok() - .flatten() - .unwrap_or("0".to_string()) - == "1" + .and_then(|o| o.get("experimental_healthcheck").ok()) + .and_then(|v| v.as_bool()) + .unwrap_or(false) { // If we are receiving a commit request, it means we are making progress and this can be considered a healthy state if let Ok(Some(_commit_request)) = poll_result.as_ref() { @@ -97,16 +97,24 @@ where #[cfg(test)] mod tests { use super::HealthCheck; - use crate::runtime_config::patch_str_config_for_test; use sentry_arroyo::processing::strategies::{ CommitRequest, ProcessingStrategy, StrategyError, SubmitError, }; use sentry_arroyo::types::Message; + use sentry_options::init_with_schemas; + use sentry_options::testing::override_options; + use serde_json::json; use std::collections::HashMap; use std::fs; use std::path::Path; + use std::sync::Once; use std::time::Duration; + static INIT: Once = Once::new(); + fn init_config() { + INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); + } + // Mock strategy that can be configured to return commit requests struct MockStrategy { return_commit_request: bool, @@ -148,7 +156,9 @@ mod tests { #[test] fn test_file_created_when_making_progress() { // Setup - patch_str_config_for_test("experimental_healthcheck", Some("1")); + init_config(); + let _guard = + override_options(&[("snuba", "experimental_healthcheck", json!(true))]).unwrap(); let file_path = format!("/tmp/healthcheck_test_{}", uuid::Uuid::new_v4()); // Create a mock strategy that returns a commit request @@ -167,7 +177,9 @@ mod tests { #[test] fn test_not_making_progress() { // Setup - patch_str_config_for_test("experimental_healthcheck", Some("1")); + init_config(); + let _guard = + override_options(&[("snuba", "experimental_healthcheck", json!(true))]).unwrap(); let file_path = format!("/tmp/healthcheck_test_{}", uuid::Uuid::new_v4()); // Create a mock strategy that doesn't return a commit request diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 2ea86bd21c4..03c7ffa14f1 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -26,6 +26,527 @@ "type": "integer", "default": 120, "description": "BLQ hysteresis in seconds. Once routing stale, keep routing messages at least (stale_threshold - static_friction) old. Set to 0 to disable friction." + }, + "enable_any_attribute_filter": { + "type": "boolean", + "default": true, + "description": "Enable translating any_attribute_filter trace-item filters into ClickHouse predicates. When false, an any_attribute_filter is treated as always-true (no filtering applied)." + }, + "aggregation_deprecation_enabled": { + "type": "boolean", + "default": true, + "description": "Enable the deprecation check that rejects deprecated aggregation expressions in EAP time-series requests. When false, the check is skipped." + }, + "enable_trace_pagination": { + "type": "boolean", + "default": true, + "description": "Enable pagination (page tokens / limit) in the EndpointGetTrace RPC. When false, the endpoint returns the full trace without pagination." + }, + "use.low.cardinality.processor": { + "type": "boolean", + "default": true, + "description": "Enable the low-cardinality query processor that hints ClickHouse to treat certain columns as low cardinality. When false, the processor is a no-op." + }, + "cross_item_queries_no_sample_outer": { + "type": "boolean", + "default": true, + "description": "For cross-item EAP queries with trace filters, skip sampling on the outer query (the inner trace-id query still samples). When false, the outer query samples at the routing tier." + }, + "default_tier": { + "type": "integer", + "default": 1, + "description": "Default storage routing tier for EAP queries when no other tier is selected. Maps to a Tier enum value (e.g. 1, 8, 64, 512)." + }, + "export_trace_items_default_page_size": { + "type": "integer", + "default": 10000, + "description": "Default page size used by the ExportTraceItems RPC when the request does not specify one." + }, + "use_sampling_factor_timestamp_seconds": { + "type": "integer", + "default": 1744131600, + "description": "Unix timestamp (seconds) cutoff controlling when the sampling-factor codepath applies for EAP queries." + }, + "EndpointGetTrace.apply_final_rollout_percentage": { + "type": "number", + "default": 0.0, + "description": "Rollout percentage (0.0-1.0) for applying the FINAL keyword in EndpointGetTrace queries." + }, + "rpc_logging_sample_rate": { + "type": "number", + "default": 0.0, + "description": "Sample rate (0.0-1.0) for logging RPC requests. 0 disables RPC request logging." + }, + "rpc_logging_flush_logs": { + "type": "number", + "default": 0.0, + "description": "When greater than 0, flush buffered RPC logs. 0 disables flushing." + }, + "ExecutionStage.max_query_size_bytes": { + "type": "integer", + "default": 131072, + "description": "Maximum allowed serialized ClickHouse query size in bytes before the execution stage rejects it." + }, + "ExecutionStage.disable_max_query_size_check_for_clusters": { + "type": "string", + "default": "", + "description": "Comma-separated list of ClickHouse cluster names for which the max-query-size check is disabled. Empty means the check applies to all clusters." + }, + "eap_items_drop_invalid_timestamps": { + "type": "boolean", + "default": false, + "description": "When true, the eap-items consumer skips messages whose event timestamp is more than one week in the future or more than thirty days in the past." + }, + "experimental_healthcheck": { + "type": "boolean", + "default": false, + "description": "When true, the consumer healthcheck strategy treats commit requests (progress) as healthy and touches the healthcheck file accordingly." + }, + "enable_cache_partitioning": { + "type": "boolean", + "default": true, + "description": "When true, query results use the per-storage cache partition; when false they fall back to the default cache partition." + }, + "randomize_query_id": { + "type": "boolean", + "default": false, + "description": "When true, assign a random ClickHouse query_id per execution instead of the deterministic content-based id." + }, + "retry_duplicate_query_id": { + "type": "boolean", + "default": false, + "description": "When true, retry a query that ClickHouse rejected because another query with the same id is already running." + }, + "enable_bypass_cache_referrers": { + "type": "boolean", + "default": false, + "description": "When true, referrers in settings.BYPASS_CACHE_REFERRERS skip the read-through query cache." + }, + "debug_buffer_size_bytes": { + "type": "integer", + "default": 0, + "description": "Size in bytes of the prefix of an INSERT stream kept in memory so a failing row can be attached to the Sentry error. 0 disables the debug buffer." + }, + "project_quota_time_percentage": { + "type": "number", + "default": 1.0, + "description": "Fraction of the counter window a project may spend on replacements before it is reported as exceeding the time limit." + }, + "counter_window_size_minutes": { + "type": "integer", + "default": 10, + "description": "Size in minutes of the rolling window the replacement bucket timer uses to track per-project processing time." + }, + "allows_skipping_single_project_replacements": { + "type": "boolean", + "default": false, + "description": "When true, a single project that exceeds the replacement time limit can be skipped (otherwise only multi-project groups are skipped)." + }, + "use_sentry_metrics": { + "type": "boolean", + "default": false, + "description": "When true, the dual-write metrics backend also emits to the Sentry metrics backend (sampled by settings.DDM_METRICS_SAMPLE_RATE)." + }, + "ondemand_profiler_hostnames": { + "type": "string", + "default": "", + "description": "Comma-separated list of hostnames for which the on-demand profiler should capture a profile." + }, + "throw_on_uniq_select_and_having": { + "type": "boolean", + "default": false, + "description": "When true, raise MismatchedAggregationException if a uniq aggregation appears in HAVING but not SELECT instead of only logging it." + }, + "function-validator.enabled": { + "type": "boolean", + "default": false, + "description": "When true, invalid function names raise InvalidFunctionCall; otherwise only an invalid_funcs metric is emitted." + }, + "mandatory_condition_enforce": { + "type": "boolean", + "default": false, + "description": "When true, queries missing mandatory condition columns raise an assertion; otherwise the omission is only logged." + }, + "eap.reject_string_timestamp_filters": { + "type": "boolean", + "default": true, + "description": "When true, reject EAP time-series filters comparing sentry.timestamp to a TYPE_STRING value." + }, + "trace_ids_cross_item_query_limit": { + "type": "integer", + "default": 50000000, + "description": "Maximum number of trace ids fetched by the cross-item-query trace-id lookup when no explicit limit is supplied." + }, + "storage_routing.enable_get_cluster_loadinfo": { + "type": "boolean", + "default": false, + "description": "When true, the storage routing strategy fetches ClickHouse cluster load info to inform routing decisions." + }, + "max_spans_per_transaction": { + "type": "integer", + "default": 2000, + "description": "Maximum number of spans processed per transaction by the transactions consumer." + }, + "admin.querylog_threads": { + "type": "integer", + "default": 4, + "description": "max_threads ClickHouse setting used by the admin querylog query; clamped to a hard maximum of 4." + }, + "enable_eap_readonly_table": { + "type": "boolean", + "default": false, + "description": "When true, non-consistent EAP-items queries are routed to the read-only table replica." + }, + "enable_events_readonly_table": { + "type": "boolean", + "default": false, + "description": "When true, non-consistent errors/events queries are routed to the read-only table replica." + }, + "use_cross_item_path_for_single_item_queries": { + "type": "boolean", + "default": false, + "description": "When true, GetTraces uses the cross-item query path for single-item queries as well as cross-item queries." + }, + "executor_queue_size_factor": { + "type": "integer", + "default": 10, + "description": "Multiplier applied to max_concurrent_queries to size the subscription executor's pending-future queue before backpressure." + }, + "snuba_api_cogs_probability": { + "type": "number", + "default": 0.0, + "description": "Sampling probability [0,1] for recording per-query COGS (cost-of-goods) accounting from the querylog." + }, + "cache_expiry_sec": { + "type": "integer", + "default": 1, + "description": "TTL in seconds applied to query-result cache entries written to Redis." + }, + "read_through_cache.short_circuit": { + "type": "boolean", + "default": false, + "description": "When true, bypass the read-through query cache entirely and call the underlying function directly (escape hatch for Redis issues)." + }, + "lw_deletions_offpeak_enabled": { + "type": "boolean", + "default": false, + "description": "When true, lightweight deletes are only processed during the configured off-peak window." + }, + "lw_deletions_offpeak_start": { + "type": "integer", + "default": 0, + "description": "UTC hour (0-24) at which the lightweight-deletes off-peak window starts." + }, + "lw_deletions_offpeak_end": { + "type": "integer", + "default": 24, + "description": "UTC hour (0-24) at which the lightweight-deletes off-peak window ends." + }, + "org_ids_delete_allowlist": { + "type": "string", + "default": "", + "description": "Comma-separated org ids allowed to issue EAP-items lightweight deletes; empty means all orgs are allowed." + }, + "max_parts_mutating_for_delete": { + "type": "integer", + "default": 20, + "description": "Maximum number of parts that may be mutating before a lightweight delete is deferred." + }, + "permit_delete_by_attribute": { + "type": "boolean", + "default": false, + "description": "When true, delete-by-attribute requests are permitted; otherwise they are ignored." + }, + "MAX_ONGOING_MUTATIONS_FOR_DELETE": { + "type": "integer", + "default": 5, + "description": "Maximum number of ongoing mutations allowed before a delete is rejected." + }, + "storage_deletes_enabled": { + "type": "boolean", + "default": true, + "description": "Master switch for storage delete (DELETE FROM) support; when false all delete requests are rejected." + }, + "enforce_max_rows_to_delete": { + "type": "boolean", + "default": true, + "description": "When true, reject deletes whose estimated row count exceeds the storage's max_rows_to_delete." + }, + "ignore_clickhouse_settings_override": { + "type": "string", + "default": "", + "description": "Comma/space-delimited ClickHouse setting names to strip from a query's settings override; empty keeps all." + }, + "enable_long_term_retention_downsampling": { + "type": "boolean", + "default": false, + "description": "When true, EAP queries older than 31 days (for non-full-retention item types) are routed to the most-downsampled tier." + }, + "storage_routing_config_override": { + "type": "string", + "default": "{}", + "description": "JSON object mapping organization_id to a per-org StorageRoutingConfig override." + }, + "default_storage_routing_config": { + "type": "string", + "default": "{}", + "description": "JSON-encoded default StorageRoutingConfig used when no per-org override applies." + }, + "subscription_primary_task_builder": { + "type": "string", + "default": "jittered", + "description": "TaskBuilderMode for the subscription scheduler: one of immediate, jittered, transition_jitter, transition_immediate." + }, + "consistent_override": { + "type": "string", + "default": "", + "description": "Semicolon-delimited referrer=probability pairs; for a matching referrer, consistency is dropped with the given probability. Empty means no override." + }, + "skip_final_subscriptions_projects": { + "type": "string", + "default": "[]", + "description": "Bracketed comma-separated list of project ids for which subscription queries skip the FINAL keyword." + }, + "post_replacement_consistency_projects_denylist": { + "type": "string", + "default": "[]", + "description": "Bracketed comma-separated list of project ids that are forced to FINAL after a replacement." + }, + "max_group_ids_exclude": { + "type": "integer", + "default": 256, + "description": "Maximum number of group ids excluded from a query before it falls back to FINAL instead of an exclusion set." + }, + "skip_seen_offsets": { + "type": "boolean", + "default": false, + "description": "When true, the replacer skips replacement messages whose offset has already been seen." + }, + "consumer_groups_to_reset_offset_check": { + "type": "string", + "default": "[]", + "description": "Bracketed comma-separated list of consumer groups whose replacer offset-seen check should be reset." + }, + "write_node_replacements_global": { + "type": "number", + "default": 1.0, + "description": "Probability [0,1] that a replacement is written to every storage node rather than only the distributed table." + }, + "replacements_bypass_projects": { + "type": "string", + "default": "[]", + "description": "JSON array of project ids for which error replacements are skipped." + }, + "generic_metrics_use_case_killswitch": { + "type": "string", + "default": "", + "description": "Substring-matched list of generic-metrics use case ids to drop in the consumer; a message is skipped when its use_case_id appears in this string." + }, + "lw_deletes_killswitch": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict mapping storage name to a string of project ids; a lightweight delete is dropped when its project id appears in that storage's entry. Storages with no entry are not killswitched. Migrated from per-storage runtime config lw_deletes_killswitch_." + }, + "lw_deletes_split_by_partition": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping storage name to a flag; a non-zero value enables splitting that storage's lightweight deletes by partition. Storages with no entry are not split. Migrated from per-storage runtime config lw_deletes_split_by_partition_." + }, + "validate_schema_sample_rate": { + "type": "object", + "additionalProperties": { "type": "number" }, + "default": {}, + "description": "Dict mapping snuba logical topic name to a [0,1] sample rate for JSON-schema validation of consumed messages. Topics with no entry default to 1.0 (always validate). Migrated from per-topic runtime config validate_schema_." + }, + "ignore_consistent_queries_sample_rate": { + "type": "object", + "additionalProperties": { "type": "number" }, + "default": {}, + "description": "Dict mapping dataset name to a [0,1] probability of dropping consistency for an otherwise-consistent query. Datasets with no entry default to 0 (never drop consistency). Migrated from per-dataset runtime config _ignore_consistent_queries_sample_rate." + }, + "mem_rate_limit_per_sec": { + "type": "object", + "additionalProperties": { "type": "number" }, + "default": {}, + "description": "Dict mapping in-process rate-limit bucket name to a maximum operations-per-second. Buckets with no entry are unlimited (rate limiter off). Migrated from per-bucket runtime config mem_rate_limit_per_sec_." + }, + "slicing_mega_cluster_partitions": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict mapping sliced storage-set name to a bracketed list of logical partitions that must query the mega cluster (e.g. \"[1, 2]\"). Storage sets with no entry never use the mega cluster. Migrated from per-storage-set runtime config slicing_mega_cluster_partitions_." + }, + "tags_hash_map_enabled": { + "type": "boolean", + "default": true, + "description": "Killswitch for the MappingOptimizer tags-hashmap query optimization (transactions, search_issues, discover, replays storages). When false the optimizer is a no-op for those storages." + }, + "generic_metrics/tags_hash_map_enabled": { + "type": "boolean", + "default": true, + "description": "Killswitch for the MappingOptimizer tags-hashmap query optimization on generic-metrics storages (distributions, gauges). When false the optimizer is a no-op." + }, + "events_tags_hash_map_enabled": { + "type": "boolean", + "default": true, + "description": "Killswitch for the MappingOptimizer tags-hashmap query optimization on the errors/errors_ro storages. When false the optimizer is a no-op." + }, + "events_flags_hash_map_enabled": { + "type": "boolean", + "default": true, + "description": "Killswitch for the MappingOptimizer flags-hashmap query optimization on the errors/errors_ro storages. When false the optimizer is a no-op." + }, + "clickhouse_load_balancing": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict mapping storage name to the ClickHouse load_balancing mode (e.g. \"in_order\", \"first_or_random\") used by that storage's consumer writer. Storages with no entry default to \"in_order\". Migrated from per-storage runtime config clickhouse_load_balancing:." + }, + "clickhouse_load_balancing_first_offset": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict mapping storage name to the first_offset used with the first_or_random load_balancing mode. Storages with no entry have no first_offset. Migrated from per-storage runtime config clickhouse_load_balancing_first_offset:." + }, + "clickhouse_max_insert_block_size": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping storage name to a max_insert_block_size override for that storage's INSERTs. Values below ClickHouse's default (1048449) are ignored. Storages with no entry use the server default. Migrated from per-storage runtime config clickhouse_max_insert_block_size:." + }, + "eap_items_dlq_grace_period_min": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping storage name to a grace period in minutes; eap-items messages from before the current partition older than this are routed to the DLQ. Storages with no entry have no grace-period dropping. Migrated from per-storage runtime config eap_items_dlq_grace_period_min:." + }, + "query_settings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict of base ClickHouse query settings (setting name -> value as a string) applied to all queries. Migrated from per-setting runtime config query_settings/." + }, + "async_query_settings": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict of ClickHouse query settings (setting name -> value as a string) applied additionally when the async override is active. Migrated from per-setting runtime config async_query_settings/." + }, + "query_settings_by_prefix": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict mapping a query prefix to a JSON-object string of ClickHouse query settings ({\"setting\": \"value\"}) applied for that prefix, overriding the base query_settings. Migrated from per-setting runtime config /query_settings/." + }, + "query_settings_by_referrer": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Dict mapping a referrer to a JSON-object string of ClickHouse query settings ({\"setting\": \"value\"}) applied for that referrer, taking precedence over prefix and base settings. Migrated from per-setting runtime config referrer//query_settings/." + }, + "optimize_parallel_threads": { + "type": "integer", + "default": 0, + "description": "Number of parallel threads the table-optimize cron uses when running OPTIMIZE. 0 (the default) means unset: the optimize command's --parallel flag is used instead. Migrated from runtime config optimize_parallel_threads." + }, + "http_batch_join_timeout": { + "type": "integer", + "default": 0, + "description": "Timeout in seconds the ClickHouse HTTP batch writer waits when joining the upload thread. 0 (the default) means unset: settings.BATCH_JOIN_TIMEOUT is used instead. Migrated from runtime config http_batch_join_timeout." + }, + "simultaneous_queries_sleep_seconds": { + "type": "integer", + "default": 0, + "description": "Base sleep in seconds used when retrying a ClickHouse query rejected with TOO_MANY_SIMULTANEOUS_QUERIES. 0 disables the native-driver retry (it re-raises immediately); the robust-execute path always uses at least 1 second. Migrated from runtime config simultaneous_queries_sleep_seconds." + }, + "max_days": { + "type": "integer", + "default": 0, + "description": "Maximum number of days a query time range may span before the lower bound is clamped. 0 means no limit (the time range is left unchanged). Migrated from runtime config max_days." + }, + "date_align_seconds": { + "type": "integer", + "default": 1, + "description": "Granularity in seconds to which query time-range bounds are aligned (truncated). Must be non-zero. Migrated from runtime config date_align_seconds." + }, + "snql_disabled_dataset": { + "type": "object", + "additionalProperties": { "type": "boolean" }, + "default": {}, + "description": "Dict mapping dataset name to a flag; when true, SnQL queries against that dataset are rejected. Datasets with no entry default to membership in settings.SNQL_DISABLED_DATASETS. Migrated from per-dataset runtime config snql_disabled_dataset__." + }, + "quantized_rebalance_consumer_group_delay_secs": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping consumer group name to a quantized rebalance delay in seconds. Consumer groups with no entry use no delay. Migrated from per-group runtime config quantized_rebalance_consumer_group_delay_secs__." + }, + "bypass_rate_limit": { + "type": "integer", + "default": 0, + "description": "When set to 1, all Redis-backed rate limits are bypassed (no limiting applied). Migrated from runtime config bypass_rate_limit." + }, + "rate_history_sec": { + "type": "integer", + "default": 3600, + "description": "Number of seconds the rate limiter keeps per-request timestamps in its Redis sorted set. Migrated from runtime config rate_history_sec." + }, + "rate_limit_shard_factor": { + "type": "integer", + "default": 1, + "description": "Number of shards each rate-limit Redis set is split into. Increasing it multiplies the number of Redis keys and reduces the size of each set. Migrated from runtime config rate_limit_shard_factor." + }, + "lightweight_deletes_sync": { + "type": "integer", + "default": -1, + "description": "Value for the ClickHouse lightweight_deletes_sync setting applied to lightweight-delete queries. -1 (the default) means unset: ClickHouse's own default (2 since 24.4) is left in place. Migrated from runtime config lightweight_deletes_sync." + }, + "lightweight_delete_mode": { + "type": "string", + "default": "", + "description": "ClickHouse lightweight_delete_mode applied to lightweight-delete queries. Empty (the default) leaves the setting unset. One of: alter_update (heavyweight ALTER UPDATE mutation), lightweight_update (lightweight if possible, else ALTER UPDATE), lightweight_update_force (lightweight if possible, else throw); any other non-empty value is treated as lightweight_update. Migrated from runtime config lightweight_delete_mode." + }, + "replacements_expiry_window_minutes": { + "type": "integer", + "default": 5, + "description": "Window in minutes for which a project that recently received replacements is kept in the auto-replacements bypass set. Migrated from runtime config replacements_expiry_window_minutes." + }, + "configurable_component_overrides": { + "type": "object", + "additionalProperties": { "type": "string" }, + "default": {}, + "description": "Override values for ConfigurableComponent (allocation policy and storage-routing strategy) configs, keyed by the fully-qualified config key '{resource}.{ClassName}.{config}' (parameterized configs append '.{param}:{value},...', sorted, with '.'/','/':' in param names/values escaped as __dot_literal__/__comma_literal__/__colon_literal__). Values are strings coerced to each config's declared type (bool/int/float/str) on read. Authoritative source for these configs; a key absent here falls back to the legacy Redis runtime config and then the code default. Migrated from per-component runtime config of the same key." + }, + "storage_routing_sampled_too_low_threshold": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping a storage-routing strategy class name to its sampled-too-low threshold; the special key 'StorageRouting' sets the global default applied when a strategy has no entry. Strategies with neither entry use 1000. Migrated from per-strategy runtime config .sampled_too_low_threshold." + }, + "storage_routing_time_budget_ms": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping a storage-routing strategy class name to its query time budget in milliseconds; the special key 'StorageRouting' sets the global default applied when a strategy has no entry. Strategies with neither entry use 8000. Migrated from per-strategy runtime config .time_budget_ms." + }, + "storage_routing_max_items_before_downsampling": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping an outcomes-based routing strategy class name to the global max number of items before downsampling (the per-org override goes through configurable_component_overrides). Strategies with no entry use 1000000000. Migrated from per-strategy runtime config .max_items_before_downsampling." + }, + "storage_routing_min_timerange_to_query_outcomes": { + "type": "object", + "additionalProperties": { "type": "integer" }, + "default": {}, + "description": "Dict mapping an outcomes-based routing strategy class name to the minimum query time range in seconds for which outcomes are queried. Strategies with no entry use 14400 (4 hours). Migrated from per-strategy runtime config .min_timerange_to_query_outcomes." + }, + "use_array_map_columns_timestamp_seconds": { + "type": "integer", + "default": 1782172800, + "description": "Unix timestamp (seconds) cutoff: EAP queries whose window starts on/after this read array attributes from the typed attributes_array_* map columns; older windows read the legacy attributes_array JSON column. 0 disables the typed-column read path entirely. Migrated from runtime config use_array_map_columns_timestamp_seconds." } } } diff --git a/snuba/admin/clickhouse/querylog.py b/snuba/admin/clickhouse/querylog.py index f78c5e9d6a7..fa6300c1802 100644 --- a/snuba/admin/clickhouse/querylog.py +++ b/snuba/admin/clickhouse/querylog.py @@ -1,4 +1,3 @@ -from snuba import state from snuba.admin.audit_log.query import audit_log from snuba.admin.clickhouse.common import ( get_ro_query_node_connection, @@ -9,14 +8,11 @@ from snuba.datasets.schemas.tables import TableSchema from snuba.datasets.storages.factory import get_storage from snuba.datasets.storages.storage_key import StorageKey +from snuba.state.sentry_options import get_int_option _MAX_CH_THREADS = 4 -class BadThreadsValue(Exception): - pass - - @audit_log def run_querylog_query(query: str, user: str) -> ClickhouseResult: """ @@ -39,17 +35,8 @@ def describe_querylog_schema() -> ClickhouseResult: def _get_clickhouse_threads() -> int: - config_threads = state.get_config("admin.querylog_threads", _MAX_CH_THREADS) - try: - return min( - int(config_threads) if config_threads is not None else _MAX_CH_THREADS, - _MAX_CH_THREADS, - ) - except ValueError: - # in case the config is set incorrectly - raise BadThreadsValue( - f"{config_threads} is not a valid configuration option for Clickhouse `max_threads`" - ) + config_threads = get_int_option("admin.querylog_threads", _MAX_CH_THREADS) + return min(config_threads, _MAX_CH_THREADS) def __run_querylog_query(query: str) -> ClickhouseResult: diff --git a/snuba/clickhouse/http.py b/snuba/clickhouse/http.py index 2b32dd5dd0c..3aa79f32127 100644 --- a/snuba/clickhouse/http.py +++ b/snuba/clickhouse/http.py @@ -23,11 +23,12 @@ from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool from urllib3.exceptions import HTTPError -from snuba import settings, state +from snuba import settings from snuba.clickhouse import DATETIME_FORMAT from snuba.clickhouse.errors import ClickhouseWriterError from snuba.clickhouse.formatter.expression import ClickhouseExpressionFormatter from snuba.clickhouse.query import Expression +from snuba.state.sentry_options import get_int_option from snuba.utils.codecs import Encoder from snuba.utils.iterators import chunked from snuba.utils.metrics import MetricsBackend @@ -315,11 +316,7 @@ def __init__( self.__statement = statement self.__buffer_size = buffer_size self.__chunk_size = chunk_size - self.__debug_buffer_size_bytes = state.get_config("debug_buffer_size_bytes", None) - assert ( - isinstance(self.__debug_buffer_size_bytes, int) - or self.__debug_buffer_size_bytes is None - ) + self.__debug_buffer_size_bytes = get_int_option("debug_buffer_size_bytes", 0) def __repr__(self) -> str: return f"<{type(self).__name__}: {self.__statement.get_qualified_table()} on {self.__pool.host}:{self.__pool.port}>" @@ -351,8 +348,11 @@ def write(self, values: Iterable[bytes]) -> None: batch.append(value) batch.close() - batch_join_timeout = state.get_config( - "http_batch_join_timeout", settings.BATCH_JOIN_TIMEOUT + # A 0 (the schema default) means "unset": fall back to the env-configured + # settings.BATCH_JOIN_TIMEOUT, preserving the prior runtime-config + # behavior where the option was only an override. + batch_join_timeout = ( + get_int_option("http_batch_join_timeout", 0) or settings.BATCH_JOIN_TIMEOUT ) # IMPORTANT: Please read the docstring of this method if you ever decide to remove the # timeout argument from the join method. diff --git a/snuba/clickhouse/native.py b/snuba/clickhouse/native.py index b919b2f7d06..b8e4b075b8f 100644 --- a/snuba/clickhouse/native.py +++ b/snuba/clickhouse/native.py @@ -26,10 +26,11 @@ from dateutil.tz import tz from sentry_sdk.integrations.logging import ignore_logger -from snuba import environment, settings, state +from snuba import environment, settings from snuba.clickhouse.errors import ClickhouseError from snuba.clickhouse.formatter.nodes import FormattedQuery from snuba.reader import Reader, Result, build_result_transformer +from snuba.state.sentry_options import get_int_option from snuba.utils.metrics.gauge import ThreadSafeGauge from snuba.utils.metrics.wrapper import MetricsWrapper @@ -239,8 +240,8 @@ def query_execute() -> Any: if attempts_remaining <= 0: raise ClickhouseError(e.message, code=e.code) from e - sleep_interval_seconds = state.get_config( - "simultaneous_queries_sleep_seconds", None + sleep_interval_seconds = get_int_option( + "simultaneous_queries_sleep_seconds", 0 ) if not sleep_interval_seconds: raise ClickhouseError(e.message, code=e.code) from e @@ -320,11 +321,11 @@ def execute_robust( attempts_remaining -= 1 if attempts_remaining <= 0: raise e - sleep_interval_seconds = state.get_config( - "simultaneous_queries_sleep_seconds", 1 + # Linear backoff. Adds one second at each iteration. Falls + # back to a 1-second base when the option is unset (0). + sleep_interval_seconds = ( + get_int_option("simultaneous_queries_sleep_seconds", 0) or 1 ) - assert sleep_interval_seconds is not None - # Linear backoff. Adds one second at each iteration. time.sleep( float((total_attempts - attempts_remaining) * sleep_interval_seconds) ) diff --git a/snuba/clickhouse/optimize/util.py b/snuba/clickhouse/optimize/util.py index 4d08c146d05..ed6dddfd3f4 100644 --- a/snuba/clickhouse/optimize/util.py +++ b/snuba/clickhouse/optimize/util.py @@ -1,7 +1,6 @@ -import typing from dataclasses import dataclass -from snuba.state import get_config +from snuba.state.sentry_options import get_int_option _OPTIMIZE_PARALLEL_THREADS_KEY = "optimize_parallel_threads" @@ -20,4 +19,7 @@ def estimated_time(self) -> float: def get_num_threads(default_parallel_threads: int) -> int: - return typing.cast(int, get_config(_OPTIMIZE_PARALLEL_THREADS_KEY, default_parallel_threads)) + # A 0 (the schema default) means "unset": fall back to the value passed via + # the optimize command's --parallel flag, preserving the prior runtime-config + # behavior where the option was only an override. + return get_int_option(_OPTIMIZE_PARALLEL_THREADS_KEY, 0) or default_parallel_threads diff --git a/snuba/configs/configuration.py b/snuba/configs/configuration.py index 2c0bfd0f097..32c2f2656fa 100644 --- a/snuba/configs/configuration.py +++ b/snuba/configs/configuration.py @@ -8,12 +8,23 @@ from snuba.state import get_all_configs as get_all_runtime_configs from snuba.state import get_config as get_runtime_config from snuba.state import set_config as set_runtime_config +from snuba.state.sentry_options import get_option from snuba.utils.registered_class import RegisteredClass logger = logging.getLogger("snuba.configurable_component") T = TypeVar("T", bound="ConfigurableComponent") +# Single sentry-options dict holding all ConfigurableComponent (allocation +# policy / routing strategy) config overrides, keyed by the same fully-qualified +# runtime-config key these configs have always used +# (``{resource}.{ClassName}.{config}[.{param}:{value},...]``). Values are stored +# as strings and coerced to each config's declared ``value_type`` on read. This +# is the authoritative, centrally-managed (sentry-options-automator) source; when +# a key is absent we fall back to the legacy Redis runtime config so existing +# values keep working during the transition. +CONFIGURABLE_COMPONENT_OVERRIDES_KEY = "configurable_component_overrides" + class InvalidConfig(Exception): pass @@ -389,20 +400,47 @@ def __build_runtime_config_key(self, config: str, params: dict[str, Any]) -> str def _get_hash(self) -> str: return self.component_namespace() + @staticmethod + def __coerce_override(value: Any, config_definition: "Configuration") -> Any: + """Coerce a sentry-options override (stored as a string) to the config's + declared ``value_type``. Falls back to the definition default if the + stored value can't be coerced.""" + value_type = config_definition.value_type + try: + if value_type is bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + return str(value).strip().lower() in ("1", "true", "yes", "on") + return value_type(value) + except (TypeError, ValueError): + return config_definition.default + def get_config_value( self, config_key: str, params: dict[str, Any] = {}, validate: bool = True, ) -> Any: - """Returns value of a configuration on this ConfigurableComponent, or the default if none exists in Redis.""" + """Returns value of a configuration on this ConfigurableComponent, or the default if none is set. + + sentry-options (managed centrally via the sentry-options-automator) is the + authoritative source. When a key is absent there we fall back to the + legacy Redis runtime config and finally the code default, so values set + the old way keep working during the transition. + """ config_definition = ( self._validate_config_params(config_key, params) if validate else self.config_definitions()[config_key] ) + full_key = self.__build_runtime_config_key(config_key, params) + overrides = get_option(CONFIGURABLE_COMPONENT_OVERRIDES_KEY, {}) + if isinstance(overrides, dict) and full_key in overrides: + return self.__coerce_override(overrides[full_key], config_definition) return get_runtime_config( - key=self.__build_runtime_config_key(config_key, params), + key=full_key, default=config_definition.default, config_key=self._get_hash(), ) diff --git a/snuba/consumers/consumer.py b/snuba/consumers/consumer.py index 875efc5128f..93524cae660 100644 --- a/snuba/consumers/consumer.py +++ b/snuba/consumers/consumer.py @@ -34,13 +34,14 @@ from confluent_kafka import Producer as ConfluentKafkaProducer from confluent_kafka import Producer as ConfluentProducer -from snuba import environment, state +from snuba import environment from snuba.clickhouse.http import JSONRow, JSONRowEncoder, ValuesRowEncoder from snuba.consumers.schemas import _NOOP_CODEC, get_json_codec from snuba.consumers.types import KafkaMessageMetadata from snuba.datasets.storages.storage_key import StorageKey from snuba.datasets.table_storage import TableWriter from snuba.processor import InsertBatch, MessageProcessor, ReplacementBatch +from snuba.state.sentry_options import get_mapped_float_option from snuba.utils.metrics import MetricsBackend from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.utils.streams.topics import Topic as SnubaTopic @@ -224,7 +225,10 @@ def close(self) -> None: self.__topic.name, key=key, value=rapidjson.dumps(value).encode("utf-8"), - on_delivery=self.__delivery_callback, + on_delivery=cast( + "Callable[[Optional[KafkaError], ConfluentMessage], None]", + self.__delivery_callback, + ), ) self.__producer.flush() @@ -315,7 +319,7 @@ def close(self) -> None: self.__commit_log_config.topic.name, key=payload.key, value=payload.value, - headers=payload.headers, + headers=cast("List[Tuple[str, Union[str, bytes, None]]]", payload.headers), on_delivery=self.__commit_message_delivery_callback, ) self.__commit_log_config.producer.poll(0.0) @@ -441,7 +445,7 @@ def close(self) -> None: self.__commit_log_config.topic.name, key=payload.key, value=payload.value, - headers=payload.headers, + headers=cast("List[Tuple[str, Union[str, bytes, None]]]", payload.headers), on_delivery=self.__commit_message_delivery_callback, ) self.__commit_log_config.producer.poll(0.0) @@ -484,7 +488,7 @@ def process_message( ) validate_sample_rate = ( - state.get_float_config(f"validate_schema_{snuba_logical_topic.name}", 1.0) or 0.0 + get_mapped_float_option("validate_schema_sample_rate", snuba_logical_topic.name, 1.0) or 0.0 ) assert isinstance(message.value, BrokerValue) diff --git a/snuba/datasets/entities/storage_selectors/eap_items.py b/snuba/datasets/entities/storage_selectors/eap_items.py index cc80c5cf7f1..58526632760 100644 --- a/snuba/datasets/entities/storage_selectors/eap_items.py +++ b/snuba/datasets/entities/storage_selectors/eap_items.py @@ -1,6 +1,5 @@ from typing import Sequence -from snuba import state from snuba.datasets.entities.storage_selectors import QueryStorageSelector from snuba.datasets.storage import EntityStorageConnection from snuba.datasets.storages.factory import get_storage @@ -8,6 +7,7 @@ from snuba.downsampled_storage_tiers import Tier from snuba.query.logical import Query from snuba.query.query_settings import HTTPQuerySettings, QuerySettings +from snuba.state.sentry_options import get_bool_option class EAPItemsStorageSelector(QueryStorageSelector): @@ -22,7 +22,7 @@ def select_storage( tier = query_settings.get_sampling_tier() use_readonly_storage = ( - state.get_config("enable_eap_readonly_table", False) + get_bool_option("enable_eap_readonly_table", False) and not query_settings.get_consistent() ) diff --git a/snuba/datasets/entities/storage_selectors/errors.py b/snuba/datasets/entities/storage_selectors/errors.py index d5ec9df084b..525c455beb0 100644 --- a/snuba/datasets/entities/storage_selectors/errors.py +++ b/snuba/datasets/entities/storage_selectors/errors.py @@ -1,6 +1,5 @@ from typing import Sequence -from snuba import state from snuba.datasets.entities.storage_selectors import QueryStorageSelector from snuba.datasets.storage import ( EntityStorageConnection, @@ -10,6 +9,7 @@ ) from snuba.query.logical import Query from snuba.query.query_settings import QuerySettings +from snuba.state.sentry_options import get_bool_option class ErrorsQueryStorageSelector(QueryStorageSelector): @@ -21,7 +21,7 @@ def select_storage( storage_connections: Sequence[EntityStorageConnection], ) -> EntityStorageConnection: use_readonly_storage = ( - state.get_config("enable_events_readonly_table", False) + get_bool_option("enable_events_readonly_table", False) and not query_settings.get_consistent() ) diff --git a/snuba/datasets/plans/cluster_selector.py b/snuba/datasets/plans/cluster_selector.py index a51d05c8f5c..4f734d4bf37 100644 --- a/snuba/datasets/plans/cluster_selector.py +++ b/snuba/datasets/plans/cluster_selector.py @@ -1,6 +1,5 @@ from abc import ABC, abstractmethod -from snuba import state from snuba.clickhouse.query import Query as ClickhouseQuery from snuba.clickhouse.query_dsl.accessors import get_object_ids_in_query_ast from snuba.clusters.cluster import ClickhouseCluster, get_cluster @@ -13,6 +12,7 @@ from snuba.datasets.storages.storage_key import StorageKey from snuba.query.logical import Query as LogicalQuery from snuba.query.query_settings import QuerySettings +from snuba.state.sentry_options import get_mapped_str_option MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX = "slicing_mega_cluster_partitions" @@ -41,11 +41,11 @@ def _should_use_mega_cluster(storage_set: StorageSetKey, logical_partition: int) to a new slice. In such cases, the old data resides in some different slice than what the new mapping says. """ - key = f"{MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX}_{storage_set.value}" - state.get_config(key, None) - slicing_read_override_config = state.get_config(key, None) + slicing_read_override_config = get_mapped_str_option( + MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX, storage_set.value, "" + ) - if slicing_read_override_config is None: + if not slicing_read_override_config: return False slicing_read_override_config = slicing_read_override_config[1:-1] diff --git a/snuba/datasets/processors/transactions_processor.py b/snuba/datasets/processors/transactions_processor.py index 859818d0b16..f2479e656fd 100644 --- a/snuba/datasets/processors/transactions_processor.py +++ b/snuba/datasets/processors/transactions_processor.py @@ -29,7 +29,7 @@ _ensure_valid_ip, _unicodify, ) -from snuba.state import get_config +from snuba.state.sentry_options import get_int_option from snuba.utils.metrics.wrapper import MetricsWrapper logger = logging.getLogger(__name__) @@ -344,12 +344,7 @@ def _process_spans( data = event_dict["data"] trace_context = data["contexts"]["trace"] - try: - max_spans_per_transaction = get_config("max_spans_per_transaction", 2000) - assert isinstance(max_spans_per_transaction, (int, float)) - except Exception: - metrics.increment("bad_config.max_spans_per_transaction") - max_spans_per_transaction = 2000 + max_spans_per_transaction = get_int_option("max_spans_per_transaction", 2000) num_processed = 0 processed_spans = [] diff --git a/snuba/environment.py b/snuba/environment.py index 26303d28b41..2ea60cbe1d2 100644 --- a/snuba/environment.py +++ b/snuba/environment.py @@ -178,6 +178,10 @@ def setup_sentry() -> None: }, ) + from snuba.state.sentry_options import init_options + + init_options() + from snuba.utils.profiler import run_ondemand_profiler if settings.SENTRY_DSN is not None: diff --git a/snuba/lw_deletions/off_peak.py b/snuba/lw_deletions/off_peak.py index 68631c1df5b..d8a933304db 100644 --- a/snuba/lw_deletions/off_peak.py +++ b/snuba/lw_deletions/off_peak.py @@ -7,7 +7,7 @@ from arroyo.processing.strategies.abstract import MessageRejected from arroyo.types import Message -from snuba.state import get_int_config +from snuba.state.sentry_options import get_bool_option, get_int_option from snuba.utils.metrics import MetricsBackend _CACHE_TTL_SECONDS = 60 @@ -49,14 +49,14 @@ def _is_off_peak(self) -> bool: if self.__cached_result is not None and (now - self.__cached_at) < _CACHE_TTL_SECONDS: return self.__cached_result - enabled = get_int_config("lw_deletions_offpeak_enabled", default=0) + enabled = get_bool_option("lw_deletions_offpeak_enabled", False) if not enabled: self.__cached_result = True self.__cached_at = now return True - start = get_int_config("lw_deletions_offpeak_start", default=0) or 0 - end = get_int_config("lw_deletions_offpeak_end", default=24) or 24 + start = get_int_option("lw_deletions_offpeak_start", 0) + end = get_int_option("lw_deletions_offpeak_end", 24) or 24 current_hour = datetime.now(timezone.utc).hour if start == end: diff --git a/snuba/lw_deletions/strategy.py b/snuba/lw_deletions/strategy.py index 73491ee9919..51c36232160 100644 --- a/snuba/lw_deletions/strategy.py +++ b/snuba/lw_deletions/strategy.py @@ -2,7 +2,6 @@ import json import logging import time -import typing from datetime import datetime, timedelta from typing import List, Mapping, Optional, Sequence, TypeVar @@ -35,7 +34,11 @@ from snuba.query.expressions import Expression, FunctionCall from snuba.query.query_settings import HTTPQuerySettings from snuba.redis import RedisClientKey, get_redis_client -from snuba.state import get_int_config, get_str_config +from snuba.state.sentry_options import ( + get_int_option, + get_mapped_int_option, + get_str_option, +) from snuba.utils.metrics import MetricsBackend from snuba.web import QueryException from snuba.web.bulk_delete_query import construct_or_conditions, construct_query @@ -85,7 +88,7 @@ def _filter_allowed_conditions( if self.__storage.get_storage_key() != StorageKey.EAP_ITEMS: return conditions - str_config = get_str_config("org_ids_delete_allowlist", "") + str_config = get_str_option("org_ids_delete_allowlist", "") if not str_config: return conditions # allowlist not set → allow all @@ -199,12 +202,13 @@ def _get_partition_dates(self, table: str) -> List[str]: def _execute_delete(self, conditions: Sequence[ConditionsBag]) -> None: self._check_ongoing_mutations() query_settings = HTTPQuerySettings() - # starting in 24.4 the default is 2 - lw_sync = get_int_config("lightweight_deletes_sync") - if lw_sync is not None: + # starting in 24.4 the default is 2; -1 (the schema default) means + # "unset", leaving ClickHouse's own default in place. + lw_sync = get_int_option("lightweight_deletes_sync", -1) + if lw_sync >= 0: query_settings.push_clickhouse_setting("lightweight_deletes_sync", lw_sync) - lw_updates_enabled = get_str_config("lightweight_delete_mode") + lw_updates_enabled = get_str_option("lightweight_delete_mode", "") if lw_updates_enabled: mode = ( lw_updates_enabled @@ -220,7 +224,7 @@ def _execute_delete(self, conditions: Sequence[ConditionsBag]) -> None: split_enabled = bool( self.__partition_column - and get_int_config(f"lw_deletes_split_by_partition_{self.__storage_name}", default=0) + and get_mapped_int_option("lw_deletes_split_by_partition", self.__storage_name, 0) ) for table in self.__tables: @@ -331,12 +335,8 @@ def _check_ongoing_mutations(self, skip_throttle: bool = False) -> None: start = time.time() parts_mutating = _num_parts_currently_mutating(self.__storage.get_cluster()) self.__last_ongoing_mutations_check = time.time() - max_parts_mutating = typing.cast( - int, - get_int_config( - "max_parts_mutating_for_delete", - default=settings.MAX_PARTS_MUTATING_FOR_DELETE, - ), + max_parts_mutating = get_int_option( + "max_parts_mutating_for_delete", settings.MAX_PARTS_MUTATING_FOR_DELETE ) self.__metrics.timing("ongoing_mutations_query_ms", (time.time() - start) * 1000) if parts_mutating > max_parts_mutating: diff --git a/snuba/pipeline/stages/query_execution.py b/snuba/pipeline/stages/query_execution.py index b7b200984ea..460b3ee67fb 100644 --- a/snuba/pipeline/stages/query_execution.py +++ b/snuba/pipeline/stages/query_execution.py @@ -5,11 +5,11 @@ from collections import defaultdict from dataclasses import replace from math import floor -from typing import Any, MutableMapping, Optional +from typing import Any, Dict, MutableMapping, Optional, cast import sentry_sdk -from snuba import environment, state +from snuba import environment from snuba import settings as snuba_settings from snuba.attribution.attribution_info import AttributionInfo from snuba.clickhouse.formatter.query import format_query @@ -30,6 +30,7 @@ ) from snuba.reader import Reader from snuba.settings import MAX_QUERY_SIZE_BYTES +from snuba.state.sentry_options import get_int_option, get_str_option from snuba.utils.metrics.gauge import Gauge from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper @@ -163,17 +164,12 @@ def _run_and_apply_column_names( def _max_query_size_bytes() -> int: - return ( - state.get_int_config(MAX_QUERY_SIZE_BYTES_CONFIG, MAX_QUERY_SIZE_BYTES) - or MAX_QUERY_SIZE_BYTES - ) + return get_int_option(MAX_QUERY_SIZE_BYTES_CONFIG, MAX_QUERY_SIZE_BYTES) or MAX_QUERY_SIZE_BYTES def _disable_max_query_size_check_for_clusters() -> set[str]: return set( - (state.get_str_config(DISABLE_MAX_QUERY_SIZE_CHECK_FOR_CLUSTERS_CONFIG, "") or "").split( - "," - ) + (get_str_option(DISABLE_MAX_QUERY_SIZE_CHECK_FOR_CLUSTERS_CONFIG, "") or "").split(",") ) @@ -252,7 +248,7 @@ def _format_storage_query_and_run( cause.__class__.__name__, str(cause), extra=QueryExtraData( - stats=stats, + stats=cast(Dict[str, Any], stats), sql=formatted_sql, experiments=clickhouse_query.get_experiments(), ), diff --git a/snuba/query/processors/logical/low_cardinality_processor.py b/snuba/query/processors/logical/low_cardinality_processor.py index 07aa77b5e48..ee8f797bfec 100644 --- a/snuba/query/processors/logical/low_cardinality_processor.py +++ b/snuba/query/processors/logical/low_cardinality_processor.py @@ -1,6 +1,5 @@ from dataclasses import replace -from snuba import state from snuba.query.expressions import ( Column, Expression, @@ -11,6 +10,7 @@ from snuba.query.logical import Query from snuba.query.processors.logical import LogicalQueryProcessor from snuba.query.query_settings import QuerySettings +from snuba.state.sentry_options import get_bool_option class LowCardinalityProcessor(LogicalQueryProcessor): @@ -66,7 +66,7 @@ def transform_expressions(exp: Expression) -> Expression: ) return exp - if state.get_int_config("use.low.cardinality.processor", 1) == 0: + if not get_bool_option("use.low.cardinality.processor", True): return query.transform_expressions(transform_expressions) diff --git a/snuba/query/processors/physical/clickhouse_settings_override.py b/snuba/query/processors/physical/clickhouse_settings_override.py index 90d15854043..694cfcbcb2a 100644 --- a/snuba/query/processors/physical/clickhouse_settings_override.py +++ b/snuba/query/processors/physical/clickhouse_settings_override.py @@ -3,7 +3,7 @@ from snuba.clickhouse.query import Query from snuba.query.processors.physical import ClickhouseQueryProcessor from snuba.query.query_settings import QuerySettings -from snuba.state import get_str_config +from snuba.state.sentry_options import get_str_option class ClickhouseSettingsOverride(ClickhouseQueryProcessor): @@ -39,7 +39,7 @@ def process_query(self, query: Query, query_settings: QuerySettings) -> None: new_settings.update(self.__settings) new_settings.update(query_settings.get_clickhouse_settings()) - ignored_settings = get_str_config("ignore_clickhouse_settings_override") + ignored_settings = get_str_option("ignore_clickhouse_settings_override", "") if ignored_settings: new_settings = { setting: value diff --git a/snuba/query/processors/physical/conditions_enforcer.py b/snuba/query/processors/physical/conditions_enforcer.py index 4b1cb9fa406..cb5faf1a234 100644 --- a/snuba/query/processors/physical/conditions_enforcer.py +++ b/snuba/query/processors/physical/conditions_enforcer.py @@ -6,7 +6,7 @@ from snuba.query.processors.condition_checkers import ConditionChecker from snuba.query.processors.physical import ClickhouseQueryProcessor from snuba.query.query_settings import QuerySettings -from snuba.state import get_config +from snuba.state.sentry_options import get_bool_option logger = logging.getLogger(__name__) @@ -48,7 +48,7 @@ def inspect_expression(condition: Expression) -> None: inspect_expression(prewhere) missing_ids = {checker.get_id() for checker in missing_checkers} - if get_config("mandatory_condition_enforce", 0): + if get_bool_option("mandatory_condition_enforce", False): assert not missing_checkers, ( f"Missing mandatory columns in query. Missing {missing_ids}" ) diff --git a/snuba/query/processors/physical/mapping_optimizer.py b/snuba/query/processors/physical/mapping_optimizer.py index 18887e20c51..54010cfb867 100644 --- a/snuba/query/processors/physical/mapping_optimizer.py +++ b/snuba/query/processors/physical/mapping_optimizer.py @@ -26,7 +26,7 @@ from snuba.query.matchers import Column as ColumnMatcher from snuba.query.processors.physical import ClickhouseQueryProcessor from snuba.query.query_settings import QuerySettings -from snuba.state import get_config +from snuba.state.sentry_options import get_bool_option from snuba.utils.metrics.wrapper import MetricsWrapper metrics = MetricsWrapper(environment.metrics, "processors.tags_hash_map") @@ -368,7 +368,7 @@ def __get_reduced_and_classified_query_clause( return clause, cond_class def process_query(self, query: Query, query_settings: QuerySettings) -> None: - if not get_config(self.__killswitch, 1): + if not get_bool_option(self.__killswitch, True): return condition, cond_class = self.__get_reduced_and_classified_query_clause( query.get_condition(), query diff --git a/snuba/query/processors/physical/replaced_groups.py b/snuba/query/processors/physical/replaced_groups.py index 4c49b1ac0d9..a6a26be880f 100644 --- a/snuba/query/processors/physical/replaced_groups.py +++ b/snuba/query/processors/physical/replaced_groups.py @@ -14,7 +14,7 @@ from snuba.query.query_settings import QuerySettings, SubscriptionQuerySettings from snuba.replacers.projects_query_flags import ProjectsQueryFlags from snuba.replacers.replacer_processor import ReplacerState -from snuba.state import get_config +from snuba.state.sentry_options import get_int_option, get_str_option from snuba.utils.metrics.wrapper import MetricsWrapper metrics = MetricsWrapper(environment.metrics, "processors.replaced_groups") @@ -51,8 +51,8 @@ def process_query(self, query: Query, query_settings: QuerySettings) -> None: self._set_query_final(query, False) return - for no_final_subscriptions_project in ( - get_config("skip_final_subscriptions_projects") or "[]" + for no_final_subscriptions_project in get_str_option( + "skip_final_subscriptions_projects", "[]" )[1:-1].split(","): if ( no_final_subscriptions_project @@ -63,8 +63,8 @@ def process_query(self, query: Query, query_settings: QuerySettings) -> None: self._set_query_final(query, False) return - for denied_project_id_string in ( - get_config("post_replacement_consistency_projects_denylist") or "[]" + for denied_project_id_string in get_str_option( + "post_replacement_consistency_projects_denylist", "[]" )[1:-1].split(","): if denied_project_id_string and int(denied_project_id_string) in project_ids: metrics.increment(name=CONSISTENCY_DENYLIST_METRIC) @@ -96,11 +96,9 @@ def process_query(self, query: Query, query_settings: QuerySettings) -> None: elif flags.group_ids_to_exclude: # If the number of groups to exclude exceeds our limit, the query # should just use final instead of the exclusion set. - max_group_ids_exclude = get_config( - "max_group_ids_exclude", - settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE, + max_group_ids_exclude = get_int_option( + "max_group_ids_exclude", settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE ) - assert isinstance(max_group_ids_exclude, int) groups_to_exclude = self._groups_to_exclude(query, flags.group_ids_to_exclude) if ( len(flags.group_ids_to_exclude) > 2 * max_group_ids_exclude diff --git a/snuba/query/processors/physical/uniq_in_select_and_having.py b/snuba/query/processors/physical/uniq_in_select_and_having.py index 8f98e16b499..8a685456d4a 100644 --- a/snuba/query/processors/physical/uniq_in_select_and_having.py +++ b/snuba/query/processors/physical/uniq_in_select_and_having.py @@ -16,7 +16,7 @@ from snuba.query.matchers import Param, String from snuba.query.processors.physical import ClickhouseQueryProcessor from snuba.query.query_settings import QuerySettings -from snuba.state import get_config +from snuba.state.sentry_options import get_bool_option class MismatchedAggregationException(InvalidQueryException): @@ -53,7 +53,7 @@ def process_query(self, query: Query, query_settings: QuerySettings) -> None: for col in selected_columns: col.expression.accept(matcher) if not all(matcher.found_expressions): - should_throw = get_config("throw_on_uniq_select_and_having", False) + should_throw = get_bool_option("throw_on_uniq_select_and_having", False) error = MismatchedAggregationException( "Aggregation is in HAVING clause but not SELECT", query=str(query) ) diff --git a/snuba/query/snql/parser.py b/snuba/query/snql/parser.py index 03e7d3f1541..aa01293afa7 100644 --- a/snuba/query/snql/parser.py +++ b/snuba/query/snql/parser.py @@ -24,7 +24,6 @@ from parsimonious.grammar import Grammar from parsimonious.nodes import Node, NodeVisitor -from snuba import state from snuba.clickhouse.columns import Array, ColumnSet from snuba.clickhouse.query_dsl.accessors import get_time_range_expressions from snuba.datasets.dataset import Dataset @@ -109,6 +108,7 @@ ) from snuba.query.snql.joins import RelationshipTuple, build_join_clause from snuba.state import explain_meta +from snuba.state.sentry_options import get_int_option from snuba.util import parse_datetime from snuba.utils.metrics.timer import Timer @@ -1267,10 +1267,11 @@ def _replace_time_condition( ) -> None: condition = query.get_condition() top_level = get_first_level_and_conditions(condition) if condition is not None else [] - max_days, date_align = state.get_configs([("max_days", None), ("date_align_seconds", 1)]) - assert isinstance(date_align, int) - if max_days is not None: - max_days = int(max_days) + # max_days defaults to 0 in the schema, which we treat as "no limit" (None) + # to preserve the prior runtime-config behavior where an unset value meant + # no clamping of the query time range. + date_align = get_int_option("date_align_seconds", 1) + max_days = get_int_option("max_days", 0) or None if isinstance(query, LogicalQuery): new_top_level = _align_max_days_date_align( diff --git a/snuba/query/validation/functions.py b/snuba/query/validation/functions.py index 93c366a9931..22939d64dfe 100644 --- a/snuba/query/validation/functions.py +++ b/snuba/query/validation/functions.py @@ -1,10 +1,11 @@ from typing import Sequence -from snuba import environment, state +from snuba import environment from snuba.query.data_source import DataSource from snuba.query.expressions import Expression from snuba.query.functions import is_valid_global_function from snuba.query.validation import FunctionCallValidator, InvalidFunctionCall +from snuba.state.sentry_options import get_bool_option from snuba.utils.metrics.wrapper import MetricsWrapper metrics = MetricsWrapper(environment.metrics, "validation.functions") @@ -22,7 +23,7 @@ def validate( if is_valid_global_function(func_name): return - if state.get_config("function-validator.enabled", False): + if get_bool_option("function-validator.enabled", False): raise InvalidFunctionCall(f"Invalid function name: {func_name}") else: metrics.increment("invalid_funcs", tags={"func_name": func_name}) diff --git a/snuba/querylog/__init__.py b/snuba/querylog/__init__.py index fcef4cccf7a..ef5032df52c 100644 --- a/snuba/querylog/__init__.py +++ b/snuba/querylog/__init__.py @@ -15,6 +15,7 @@ from snuba.query.exceptions import QueryPlanException from snuba.querylog.query_metadata import QueryStatus, SnubaQueryMetadata, Status from snuba.request import Request +from snuba.state.sentry_options import get_float_option from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.web import QueryException, QueryResult @@ -138,7 +139,7 @@ def _record_cogs( cluster_name = query_metadata.query_list[0].stats.get("cluster_name", "") if cluster_name.startswith("snuba-events-analytics-platform"): - if random() < (state.get_config("snuba_api_cogs_probability") or 0): + if random() < get_float_option("snuba_api_cogs_probability", 0.0): record_cogs( resource_id="eap_clickhouse", app_feature=_get_eap_app_feature(request), @@ -173,7 +174,7 @@ def _record_cogs( .replace("_0", "") ) - if random() < (state.get_config("snuba_api_cogs_probability") or 0): + if random() < get_float_option("snuba_api_cogs_probability", 0.0): record_cogs( resource_id=f"{cluster_name}", app_feature=app_feature, diff --git a/snuba/replacer.py b/snuba/replacer.py index 6cf9e7f547b..bed627ef643 100644 --- a/snuba/replacer.py +++ b/snuba/replacer.py @@ -48,7 +48,7 @@ ReplacementMessage, ReplacementMessageMetadata, ) -from snuba.state import get_int_config, get_str_config +from snuba.state.sentry_options import get_bool_option, get_str_option from snuba.utils.bucket_timer import Counter from snuba.utils.metrics import MetricsBackend from snuba.utils.rate_limiter import RateLimiter @@ -422,7 +422,7 @@ def process_message( "offset": metadata.offset, }, ) - if get_int_config("skip_seen_offsets"): + if get_bool_option("skip_seen_offsets", False): return None seq_message = json.loads(message.payload.value) [version, action_type, data] = seq_message @@ -530,7 +530,7 @@ def _reset_offset_check(self, key: str) -> None: temporarily, then cleared once relevant consumers restart. """ # expected format is "[consumer_group1,consumer_group2,..]" - consumer_groups = (get_str_config(RESET_CHECK_CONFIG) or "[]")[1:-1].split(",") + consumer_groups = get_str_option(RESET_CHECK_CONFIG, "[]")[1:-1].split(",") if self.__consumer_group in consumer_groups: self.__last_offset_processed_per_partition[key] = -1 redis_client.delete(key) diff --git a/snuba/replacers/errors_replacer.py b/snuba/replacers/errors_replacer.py index 011601b4ea5..1924bac70e5 100644 --- a/snuba/replacers/errors_replacer.py +++ b/snuba/replacers/errors_replacer.py @@ -55,7 +55,7 @@ ReplacerProcessor, ReplacerState, ) -from snuba.state import get_config, get_float_config +from snuba.state.sentry_options import get_float_option, get_str_option from snuba.utils.metrics.wrapper import MetricsWrapper """ @@ -109,8 +109,7 @@ def get_project_id(self) -> int: raise NotImplementedError() def should_write_every_node(self) -> bool: - write_node_replacement_setting = get_float_config("write_node_replacements_global", 1.0) - assert isinstance(write_node_replacement_setting, float) + write_node_replacement_setting = get_float_option("write_node_replacements_global", 1.0) if random.random() < write_node_replacement_setting: return True return False @@ -192,7 +191,7 @@ def process_message( raise InvalidMessageType("Invalid message type: {}".format(type_)) if processed is not None: - manual_bypass_projects = get_config("replacements_bypass_projects", "[]") + manual_bypass_projects = get_str_option("replacements_bypass_projects", "[]") auto_bypass_projects = list( get_config_auto_replacements_bypass_projects(datetime.now()).keys() ) diff --git a/snuba/replacers/projects_query_flags.py b/snuba/replacers/projects_query_flags.py index b1e59f99341..99699aa7ad5 100644 --- a/snuba/replacers/projects_query_flags.py +++ b/snuba/replacers/projects_query_flags.py @@ -13,7 +13,7 @@ from snuba.processor import ReplacementType from snuba.redis import RedisClientKey, get_redis_client from snuba.replacers.replacer_processor import ReplacerState -from snuba.state import get_config +from snuba.state.sentry_options import get_int_option redis_client = get_redis_client(RedisClientKey.REPLACEMENTS_STORE) @@ -82,11 +82,9 @@ def set_project_exclude_groups( # the redis key size limit is defined as 2 times the clickhouse query size # limit. there is an explicit check in the query processor for the same # limit - max_group_ids_exclude = get_config( - "max_group_ids_exclude", - settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE, + max_group_ids_exclude = get_int_option( + "max_group_ids_exclude", settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE ) - assert isinstance(max_group_ids_exclude, int) group_id_data: MutableMapping[str | bytes, bytes | float | int | str] = {} for group_id in group_ids: diff --git a/snuba/replacers/replacements_and_expiry.py b/snuba/replacers/replacements_and_expiry.py index a34adcb24c4..1c6163a9b4b 100644 --- a/snuba/replacers/replacements_and_expiry.py +++ b/snuba/replacers/replacements_and_expiry.py @@ -2,7 +2,6 @@ import logging import time -import typing from datetime import datetime, timedelta from typing import Mapping, Sequence @@ -11,7 +10,7 @@ from snuba import environment from snuba.redis import RedisClientKey, get_redis_client -from snuba.state import get_int_config +from snuba.state.sentry_options import get_int_option from snuba.utils.metrics.wrapper import MetricsWrapper metrics = MetricsWrapper(environment.metrics, "replacements_and_expiry") @@ -30,9 +29,7 @@ def set_config_auto_replacements_bypass_projects( try: projects_within_expiry = get_config_auto_replacements_bypass_projects(curr_time) start = time.time() - expiry_window = typing.cast( - int, get_int_config(key=REPLACEMENTS_EXPIRY_WINDOW_MINUTES_KEY, default=5) - ) + expiry_window = get_int_option(REPLACEMENTS_EXPIRY_WINDOW_MINUTES_KEY, 5) with redis_client.pipeline() as pipeline: for project_id in new_project_ids: if project_id not in projects_within_expiry: diff --git a/snuba/request/validation.py b/snuba/request/validation.py index 43ea9643f16..3e54c940659 100644 --- a/snuba/request/validation.py +++ b/snuba/request/validation.py @@ -7,7 +7,7 @@ import sentry_sdk -from snuba import environment, settings, state +from snuba import environment, settings from snuba.attribution import get_app_id from snuba.attribution.attribution_info import AttributionInfo from snuba.clickhouse.query_dsl.accessors import get_object_ids_in_query_ast @@ -30,6 +30,7 @@ from snuba.request import Request from snuba.request.exceptions import InvalidJsonRequestException from snuba.request.schema import RequestParts, RequestSchema +from snuba.state.sentry_options import get_mapped_bool_option, get_str_option from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper @@ -71,8 +72,8 @@ def parse_mql_query( def _consistent_override(original_setting: bool, referrer: str) -> bool: - consistent_config = state.get_config("consistent_override", None) - if isinstance(consistent_config, str): + consistent_config = get_str_option("consistent_override", "") + if consistent_config: referrers_override = consistent_config.split(";") for config in referrers_override: referrer_config, percentage = config.split("=") @@ -111,8 +112,9 @@ def build_request( with sentry_sdk.start_span(description="build_request", op="validate") as span: try: dataset_name = get_dataset_name(dataset) - if state.get_config( - f"snql_disabled_dataset__{dataset_name}", + if get_mapped_bool_option( + "snql_disabled_dataset", + dataset_name, dataset_name in settings.SNQL_DISABLED_DATASETS, ): raise InvalidQueryException(f"snql is disabled for dataset {dataset}") diff --git a/snuba/state/cache/redis/backend.py b/snuba/state/cache/redis/backend.py index 375f1ab18cb..eeb70166efc 100644 --- a/snuba/state/cache/redis/backend.py +++ b/snuba/state/cache/redis/backend.py @@ -8,8 +8,8 @@ from snuba import environment, settings from snuba.redis import RedisClientType -from snuba.state import get_config from snuba.state.cache.abstract import Cache, TValue +from snuba.state.sentry_options import get_bool_option, get_int_option from snuba.utils.codecs import ExceptionAwareCodec from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper @@ -76,7 +76,7 @@ def set(self, key: str, value: TValue) -> None: self.__client.set( self.__build_key(key), self.__codec.encode(value), - ex=get_config("cache_expiry_sec", 1), + ex=get_int_option("cache_expiry_sec", 1), ) def __get_value_with_simple_readthrough( @@ -113,7 +113,7 @@ def __get_value_with_simple_readthrough( self.__client.set( result_key, self.__codec.encode(value), - ex=get_config("cache_expiry_sec", 1), + ex=get_int_option("cache_expiry_sec", 1), ) except Exception as e: @@ -140,7 +140,7 @@ def get_readthrough( ) -> TValue: # in case something is wrong with redis, we want to be able to # disable the read_through_cache but still serve traffic. - if get_config("read_through_cache.short_circuit", 0): + if get_bool_option("read_through_cache.short_circuit", False): return function() try: diff --git a/snuba/state/rate_limit.py b/snuba/state/rate_limit.py index ae4c0bb0012..9c693f349df 100644 --- a/snuba/state/rate_limit.py +++ b/snuba/state/rate_limit.py @@ -16,6 +16,7 @@ from snuba import environment, state from snuba.redis import RedisClientKey, get_redis_client from snuba.state import get_configs, set_config +from snuba.state.sentry_options import get_int_option from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.utils.serializable_exception import SerializableException @@ -346,24 +347,14 @@ def rate_limit( # will raise RateLimitExceeded if the rate limit is exceeded """ - ( - bypass_rate_limit, - rate_history_s, - rate_limit_shard_factor, - ) = state.get_configs( - [ - # bool (0/1) flag to disable rate limits altogether - ("bypass_rate_limit", 0), - # number of seconds the timestamps are kept - ("rate_history_sec", 3600), - # number of shards that each redis set is supposed to have. - # increasing this value multiplies the number of redis keys by that - # factor, and (on average) reduces the size of each redis set - ("rate_limit_shard_factor", 1), - ] - ) - assert isinstance(rate_history_s, int) - assert isinstance(rate_limit_shard_factor, int) + # bool (0/1) flag to disable rate limits altogether + bypass_rate_limit = get_int_option("bypass_rate_limit", 0) + # number of seconds the timestamps are kept + rate_history_s = get_int_option("rate_history_sec", 3600) + # number of shards that each redis set is supposed to have. increasing this + # value multiplies the number of redis keys by that factor, and (on average) + # reduces the size of each redis set + rate_limit_shard_factor = get_int_option("rate_limit_shard_factor", 1) assert rate_limit_shard_factor > 0 if bypass_rate_limit == 1: diff --git a/snuba/state/sentry_options.py b/snuba/state/sentry_options.py new file mode 100644 index 00000000000..f16f1ad6aec --- /dev/null +++ b/snuba/state/sentry_options.py @@ -0,0 +1,184 @@ +"""Thin wrapper around the ``sentry_options`` client for Snuba. + +This is the Python counterpart to the Rust consumers' use of the +``sentry-options`` crate (see ``rust_snuba/src/strategies/blq_router.rs``). +Both read the same ``snuba`` namespace, whose schema lives in +``sentry-options/schemas/snuba/schema.json`` and whose values are managed in +sentry-options-automator and delivered as volume-mounted JSON. + +Unlike runtime config (``snuba.state.get_config`` and friends, backed by +Redis), sentry-options values are read-only from Snuba's perspective: they are +edited centrally and synced into the process, with no in-Snuba write path. +""" + +from __future__ import annotations + +import logging + +import sentry_options +from sentry_options import OptionValue + +logger = logging.getLogger(__name__) + +# Namespace Snuba registers with sentry-options. Must match the directory name +# under ``sentry-options/schemas/`` and the namespace the Rust consumers use. +SNUBA_OPTIONS_NAMESPACE = "snuba" + +_initialized = False + + +def init_options() -> None: + """Initialize the sentry-options client once per process. + + Schemas and values are discovered via the ``sentry_options`` fallback chain + (the ``SENTRY_OPTIONS_DIR`` env var, then ``/etc/sentry-options``, then + ``./sentry-options``). Safe to call repeatedly; only the first successful + call does any work. + + Failures are logged but never raised: a missing or misconfigured options + mount must not take down a service at startup. When initialization fails, + :func:`get_option` falls back to the default passed by each call site, so + behavior matches the pre-sentry-options world. + """ + global _initialized + if _initialized: + return + try: + sentry_options.init() + _initialized = True + except Exception: + logger.warning("Failed to initialize sentry-options", exc_info=True) + + +def get_option(key: str, default: OptionValue) -> OptionValue: + """Read ``key`` from the Snuba sentry-options namespace. + + Returns the configured value, or the schema default when no value is set. + If sentry-options is unavailable for any reason — not initialized, unknown + option, or any other client error — ``default`` is returned, so call sites + behave exactly as they did before the option existed. + """ + try: + return sentry_options.options(SNUBA_OPTIONS_NAMESPACE).get(key) + except sentry_options.OptionsError: + # Expected fallbacks: the client never initialized + # (NotInitializedError), the option/namespace is unknown, or the + # schema is invalid. These all subclass OptionsError; return the + # call-site default silently so behavior matches the pre-option world. + return default + except Exception: + # The client should only ever raise OptionsError, but a hot query path + # must never crash on a config read: honor the "any reason" contract + # above and log the unexpected error so it is still noticed. + logger.warning( + "Unexpected error reading sentry-option %r; using default", key, exc_info=True + ) + return default + + +def _coerce_bool(value: OptionValue, default: bool) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return bool(value) + if isinstance(value, str): + return value.strip().lower() in ("1", "true", "yes", "on") + return default + + +def _coerce_int(value: OptionValue, default: int) -> int: + if isinstance(value, bool): + return int(value) + if isinstance(value, (int, float, str)): + try: + return int(value) + except (TypeError, ValueError): + return default + return default + + +def _coerce_float(value: OptionValue, default: float) -> float: + if isinstance(value, bool): + return float(value) + if isinstance(value, (int, float, str)): + try: + return float(value) + except (TypeError, ValueError): + return default + return default + + +def _coerce_str(value: OptionValue, default: str) -> str: + if isinstance(value, str): + return value + return default + + +def get_bool_option(key: str, default: bool) -> bool: + """Read ``key`` as a bool. Replaces ``state.get_int_config`` used as a flag. + + The schema type for these keys is ``boolean``, so ``get`` returns a real + ``bool``; the int/str coercion only guards against a misconfigured value + and otherwise falls back to ``default``. + """ + return _coerce_bool(get_option(key, default), default) + + +def get_int_option(key: str, default: int) -> int: + """Read ``key`` as an int. Counterpart to ``state.get_int_config``.""" + return _coerce_int(get_option(key, default), default) + + +def get_float_option(key: str, default: float) -> float: + """Read ``key`` as a float. Counterpart to ``state.get_float_config``.""" + return _coerce_float(get_option(key, default), default) + + +def get_str_option(key: str, default: str) -> str: + """Read ``key`` as a str. Counterpart to ``state.get_str_config``.""" + return _coerce_str(get_option(key, default), default) + + +def get_mapped_option(key: str, name: str, default: OptionValue) -> OptionValue: + """Read one entry from a dict-typed option keyed by a dynamic ``name``. + + Some runtime-config keys were named dynamically — one Redis key per + storage, topic, dataset, or bucket (``f"{prefix}_{name}"``). A static + sentry-options schema cannot enumerate those, so the migration collapses + each family into a single ``object`` option ``key`` — a dictionary declared + with ``additionalProperties`` and defaulting to ``{}`` — whose value maps + the dynamic ``name`` to its value. + + Returns the entry for ``name``; falls back to ``default`` when the option + is unset/empty, is not a dictionary, or has no entry for ``name``. Because + a dict option allows arbitrary keys of the declared value type, the typed + wrappers below still coerce the entry defensively. + """ + mapping = get_option(key, {}) + if isinstance(mapping, dict) and name in mapping: + return mapping[name] + return default + + +def get_mapped_bool_option(key: str, name: str, default: bool) -> bool: + """``get_bool_option`` for one entry of a JSON-object option (see + :func:`get_mapped_option`).""" + return _coerce_bool(get_mapped_option(key, name, default), default) + + +def get_mapped_int_option(key: str, name: str, default: int) -> int: + """``get_int_option`` for one entry of a JSON-object option (see + :func:`get_mapped_option`).""" + return _coerce_int(get_mapped_option(key, name, default), default) + + +def get_mapped_float_option(key: str, name: str, default: float) -> float: + """``get_float_option`` for one entry of a JSON-object option (see + :func:`get_mapped_option`).""" + return _coerce_float(get_mapped_option(key, name, default), default) + + +def get_mapped_str_option(key: str, name: str, default: str) -> str: + """``get_str_option`` for one entry of a JSON-object option (see + :func:`get_mapped_option`).""" + return _coerce_str(get_mapped_option(key, name, default), default) diff --git a/snuba/subscriptions/executor_consumer.py b/snuba/subscriptions/executor_consumer.py index 18c67de7ff4..8049f4e96fb 100644 --- a/snuba/subscriptions/executor_consumer.py +++ b/snuba/subscriptions/executor_consumer.py @@ -21,7 +21,6 @@ from arroyo.processing.strategies.produce import Produce from arroyo.types import Commit -from snuba import state from snuba.clickhouse.errors import ClickhouseError from snuba.consumers.utils import get_partition_count from snuba.datasets.dataset import Dataset @@ -30,6 +29,7 @@ from snuba.datasets.factory import get_dataset from snuba.datasets.table_storage import KafkaTopicSpec from snuba.reader import Result +from snuba.state.sentry_options import get_int_option from snuba.subscriptions.codecs import ( SubscriptionScheduledTaskEncoder, SubscriptionTaskResultEncoder, @@ -324,8 +324,7 @@ def submit(self, message: Message[KafkaPayload]) -> None: # If there are max_concurrent_queries + 10 pending futures in the queue, # we will start raising MessageRejected to slow down the consumer as # it means our executor cannot keep up - queue_size_factor = state.get_config("executor_queue_size_factor", 10) - assert queue_size_factor is not None, "Invalid executor_queue_size_factor config" + queue_size_factor = get_int_option("executor_queue_size_factor", 10) max_queue_size = self.__max_concurrent_queries * queue_size_factor # Tell the consumer to pause until we have removed some futures from diff --git a/snuba/subscriptions/scheduler.py b/snuba/subscriptions/scheduler.py index 767f80837c6..3b1317363f8 100644 --- a/snuba/subscriptions/scheduler.py +++ b/snuba/subscriptions/scheduler.py @@ -13,13 +13,14 @@ Tuple, ) -from snuba import settings, state +from snuba import settings from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.datasets.slicing import ( map_logical_partition_to_slice, map_org_id_to_logical_partition, ) +from snuba.state.sentry_options import get_str_option from snuba.subscriptions.data import ( PartitionId, ScheduledSubscriptionTask, @@ -192,7 +193,7 @@ def get_start_mode(self, transition_mode: TaskBuilderMode) -> TaskBuilderMode: def get_current_mode(self, subscription: Subscription, timestamp: int) -> TaskBuilderMode: general_mode = TaskBuilderMode( - state.get_config("subscription_primary_task_builder", TaskBuilderMode.JITTERED) + get_str_option("subscription_primary_task_builder", TaskBuilderMode.JITTERED.value) ) if general_mode == TaskBuilderMode.IMMEDIATE or general_mode == TaskBuilderMode.JITTERED: @@ -337,7 +338,7 @@ def __reset_builder(self) -> None: This function is called for every tick. """ general_mode = TaskBuilderMode( - state.get_config("subscription_primary_task_builder", TaskBuilderMode.JITTERED) + get_str_option("subscription_primary_task_builder", TaskBuilderMode.JITTERED.value) ) if general_mode == TaskBuilderMode.JITTERED: self.__builder: TaskBuilder = self.__jittered_builder diff --git a/snuba/utils/bucket_timer.py b/snuba/utils/bucket_timer.py index 7bd1f05fd63..726d00b16c2 100644 --- a/snuba/utils/bucket_timer.py +++ b/snuba/utils/bucket_timer.py @@ -1,12 +1,11 @@ from __future__ import annotations -import typing from collections import defaultdict from datetime import datetime, timedelta from typing import List, MutableMapping -from snuba import environment, state -from snuba.state import get_int_config +from snuba import environment +from snuba.state.sentry_options import get_bool_option, get_float_option, get_int_option from snuba.utils.metrics.wrapper import MetricsWrapper metrics = MetricsWrapper(environment.metrics, "bucket_timer") @@ -37,11 +36,8 @@ def __init__(self, consumer_group: str) -> None: self.consumer_group: str = consumer_group self.buckets: Buckets = {} - percentage = state.get_config("project_quota_time_percentage", 1.0) - assert isinstance(percentage, float) - counter_window_size_minutes = typing.cast( - int, get_int_config(key="counter_window_size_minutes", default=10) - ) + percentage = get_float_option("project_quota_time_percentage", 1.0) + counter_window_size_minutes = get_int_option("counter_window_size_minutes", 10) self.counter_window_size = timedelta(minutes=counter_window_size_minutes) self.limit = self.counter_window_size * percentage @@ -92,7 +88,7 @@ def get_projects_exceeding_limit(self) -> List[int]: for project_id, total_processing_time in project_groups.items(): if total_processing_time > self.limit and ( len(project_groups) > 1 - or get_int_config("allows_skipping_single_project_replacements", default=0) + or get_bool_option("allows_skipping_single_project_replacements", False) ): projects_exceeding_time_limit.append(project_id) diff --git a/snuba/utils/metrics/backends/dualwrite.py b/snuba/utils/metrics/backends/dualwrite.py index a7f1cc1ba00..91584b077a4 100644 --- a/snuba/utils/metrics/backends/dualwrite.py +++ b/snuba/utils/metrics/backends/dualwrite.py @@ -23,9 +23,9 @@ def __init__( self.sentry = sentry def _use_sentry(self) -> bool: - from snuba import state + from snuba.state.sentry_options import get_bool_option - if str(state.get_config("use_sentry_metrics", "0")) == "1": + if get_bool_option("use_sentry_metrics", False): return bool(random.random() < settings.DDM_METRICS_SAMPLE_RATE) return False diff --git a/snuba/utils/profiler.py b/snuba/utils/profiler.py index fece6ac753c..3d0512d647d 100644 --- a/snuba/utils/profiler.py +++ b/snuba/utils/profiler.py @@ -7,7 +7,7 @@ import sentry_sdk from sentry_sdk.tracing import NoOpSpan, Transaction -from snuba.state import get_config +from snuba.state.sentry_options import get_str_option logger = logging.getLogger(__name__) @@ -22,8 +22,7 @@ def _profiler_main() -> None: own_hostname = socket.gethostname() while True: - queried_hostnames = get_config("ondemand_profiler_hostnames") or "" - queried_hostnames = queried_hostnames.split(",") + queried_hostnames = get_str_option("ondemand_profiler_hostnames", "").split(",") if own_hostname in queried_hostnames and current_transaction is None: # Log an error to Sentry on purpose, if the pod slows down it diff --git a/snuba/utils/rate_limiter.py b/snuba/utils/rate_limiter.py index 04b0a878cfb..fc76f9eb80c 100644 --- a/snuba/utils/rate_limiter.py +++ b/snuba/utils/rate_limiter.py @@ -5,9 +5,12 @@ from threading import Lock from typing import Any, Optional, Tuple -from snuba import state +from snuba.state.sentry_options import get_mapped_float_option -RATE_LIMIT_PER_SEC_KEY_PREFIX = "mem_rate_limit_per_sec_" +# sentry-options dict option whose keys are rate-limit bucket names and whose +# values are the per-bucket max operations-per-second. Migrated from the +# per-bucket runtime config keys "mem_rate_limit_per_sec_". +RATE_LIMIT_PER_SEC_OPTION = "mem_rate_limit_per_sec" class RateLimitResult(Enum): @@ -39,7 +42,7 @@ def __init__(self, bucket: str, max_rate_per_sec: Optional[float] = None) -> Non def __enter__(self) -> Tuple[RateLimitResult, int]: limit = ( - state.get_config(f"{RATE_LIMIT_PER_SEC_KEY_PREFIX}{self.__bucket}", None) + get_mapped_float_option(RATE_LIMIT_PER_SEC_OPTION, self.__bucket, 0.0) if not self.__max_rate_per_sec else self.__max_rate_per_sec ) diff --git a/snuba/web/bulk_delete_query.py b/snuba/web/bulk_delete_query.py index 336bd65ccf1..6cec34e0090 100644 --- a/snuba/web/bulk_delete_query.py +++ b/snuba/web/bulk_delete_query.py @@ -25,7 +25,7 @@ from snuba.query.exceptions import InvalidQueryException, NoRowsToDeleteException from snuba.query.expressions import Expression from snuba.reader import Result -from snuba.state import get_int_config, get_str_config +from snuba.state.sentry_options import get_bool_option, get_mapped_str_option from snuba.utils.metrics.util import with_span from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.utils.schemas import ColumnValidator, InvalidColumnType @@ -228,7 +228,7 @@ def delete_from_storage( if attribute_conditions: _validate_attribute_conditions(attribute_conditions, delete_settings) - if not get_int_config("permit_delete_by_attribute", default=0): + if not get_bool_option("permit_delete_by_attribute", False): metrics.increment("delete_query.delete_ignored") return {} @@ -373,5 +373,5 @@ def construct_or_conditions( def should_use_killswitch(storage_name: str, project_id: str) -> bool: - killswitch_config = get_str_config(f"lw_deletes_killswitch_{storage_name}", default="") + killswitch_config = get_mapped_str_option("lw_deletes_killswitch", storage_name, "") return project_id in killswitch_config if killswitch_config else False diff --git a/snuba/web/db_query.py b/snuba/web/db_query.py index ff8344330a6..5629002567f 100644 --- a/snuba/web/db_query.py +++ b/snuba/web/db_query.py @@ -15,7 +15,7 @@ from sentry_kafka_schemas.schema_types import snuba_queries_v1 from sentry_sdk.api import configure_scope -from snuba import environment, settings, state +from snuba import environment, settings from snuba.attribution.attribution_info import AttributionInfo from snuba.clickhouse.errors import ClickhouseError from snuba.clickhouse.formatter.nodes import FormattedQuery @@ -58,6 +58,12 @@ ) from snuba.state.quota import ResourceQuota from snuba.state.rate_limit import RateLimitExceeded +from snuba.state.sentry_options import ( + get_bool_option, + get_mapped_float_option, + get_mapped_str_option, + get_option, +) from snuba.util import force_bytes from snuba.utils.codecs import ExceptionAwareCodec from snuba.utils.metrics.timer import Timer @@ -201,7 +207,7 @@ def get_query_cache_key(formatted_query: FormattedQuery) -> str: def _get_cache_partition(reader: Reader) -> Cache[Result]: - enable_cache_partitioning = state.get_config("enable_cache_partitioning", 1) + enable_cache_partitioning = get_bool_option("enable_cache_partitioning", True) if not enable_cache_partitioning: return cache_partitions[DEFAULT_CACHE_PARTITION_ID] @@ -235,7 +241,7 @@ def execute_query_with_query_id( robust: bool, referrer: str, ) -> Result: - if state.get_config("randomize_query_id", False): + if get_bool_option("randomize_query_id", False): query_id = uuid.uuid4().hex else: query_id = get_query_cache_key(formatted_query) @@ -254,7 +260,7 @@ def execute_query_with_query_id( referrer, ) except ClickhouseError as e: - if e.code != ErrorCodes.QUERY_WITH_SAME_ID_IS_ALREADY_RUNNING or not state.get_config( + if e.code != ErrorCodes.QUERY_WITH_SAME_ID_IS_ALREADY_RUNNING or not get_bool_option( "retry_duplicate_query_id", False ): raise @@ -291,8 +297,8 @@ def execute_query_with_readthrough_caching( query_id: str, referrer: str, ) -> Result: - if referrer in settings.BYPASS_CACHE_REFERRERS and state.get_config( - "enable_bypass_cache_referrers" + if referrer in settings.BYPASS_CACHE_REFERRERS and get_bool_option( + "enable_bypass_cache_referrers", False ): query_id = f"randomized-{uuid.uuid4().hex}" clickhouse_query_settings["query_id"] = query_id @@ -348,44 +354,61 @@ def record_cache_hit_type(hit_type: int) -> None: ) +def _query_settings_dict(option: str) -> Mapping[str, Any]: + """A dict-typed sentry-option of {clickhouse_setting: value}.""" + value = get_option(option, {}) + return value if isinstance(value, dict) else {} + + +def _query_settings_override(option: str, name: str) -> Mapping[str, Any]: + """One entry of a dict-typed sentry-option whose values are JSON-object + strings ({"clickhouse_setting": "value"}), keyed by ``name`` (a query + prefix or referrer). sentry-options can't express dict-of-dict natively, so + the second level is a JSON string parsed here.""" + raw = get_mapped_str_option(option, name, "") + if not raw: + return {} + try: + parsed = rapidjson.loads(raw) + except (TypeError, ValueError): + return {} + return parsed if isinstance(parsed, dict) else {} + + def _get_query_settings_from_config( override_prefix: Optional[str], async_override: bool, referrer: Optional[str], ) -> MutableMapping[str, Any]: """ - Helper function to get the query settings from the config. Order of precedence - for overlapping config within this method is: - 1. referrer//query_settings/ - 2. /query_settings/ - 3. query_settings/ + Helper function to get the query settings from sentry-options. Order of + precedence for overlapping settings within this method is: + 1. referrer/ (query_settings_by_referrer) + 2. (query_settings_by_prefix) + 3. base (query_settings) #TODO: Make this configurable by entity/dataset. Since we want to use # different settings across different clusters belonging to the # same entity/dataset, using cache_partition right now. This is # not ideal but it works for now. """ - all_confs = state.get_all_configs() - - # Populate the query settings with the default values - clickhouse_query_settings: MutableMapping[str, Any] = { - k.split("/", 1)[1]: v for k, v in all_confs.items() if k.startswith("query_settings/") - } + # Populate the query settings with the base values. + clickhouse_query_settings: MutableMapping[str, Any] = dict( + _query_settings_dict("query_settings") + ) if async_override: - for k, v in all_confs.items(): - if k.startswith("async_query_settings/"): - clickhouse_query_settings[k.split("/", 1)[1]] = v + clickhouse_query_settings.update(_query_settings_dict("async_query_settings")) if override_prefix: - for k, v in all_confs.items(): - if k.startswith(f"{override_prefix}/query_settings/"): - clickhouse_query_settings[k.split("/", 2)[2]] = v + clickhouse_query_settings.update( + _query_settings_override("query_settings_by_prefix", override_prefix) + ) if referrer: - for k, v in all_confs.items(): - if k.startswith(f"referrer/{referrer}/query_settings/"): - clickhouse_query_settings[k.split("/", 3)[3]] = v + clickhouse_query_settings.update( + _query_settings_override("query_settings_by_referrer", referrer) + ) return clickhouse_query_settings @@ -428,9 +451,10 @@ def _raw_query( consistent = query_settings.get_consistent() stats["consistent"] = consistent if consistent: - sample_rate = state.get_config(f"{dataset_name}_ignore_consistent_queries_sample_rate", 0) - assert sample_rate is not None - ignore_consistent = random.random() < float(sample_rate) + sample_rate = get_mapped_float_option( + "ignore_consistent_queries_sample_rate", dataset_name, 0.0 + ) + ignore_consistent = random.random() < sample_rate if not ignore_consistent: clickhouse_query_settings["load_balancing"] = "in_order" clickhouse_query_settings["max_threads"] = 1 diff --git a/snuba/web/delete_query.py b/snuba/web/delete_query.py index 5fad9069525..f2c0b077c05 100644 --- a/snuba/web/delete_query.py +++ b/snuba/web/delete_query.py @@ -36,7 +36,7 @@ from snuba.query.expressions import Expression, FunctionCall from snuba.query.query_settings import HTTPQuerySettings from snuba.reader import Result -from snuba.state import get_config, get_int_config +from snuba.state.sentry_options import get_bool_option, get_int_option from snuba.utils.metrics.util import with_span from snuba.utils.schemas import ColumnValidator, InvalidColumnType from snuba.web import QueryException, QueryExtraData, QueryResult @@ -97,9 +97,8 @@ def delete_from_storage( # fail if too many mutations ongoing ongoing_mutations = _num_ongoing_mutations(storage.get_cluster(), delete_settings.tables) - max_ongoing_mutations = get_int_config( - "MAX_ONGOING_MUTATIONS_FOR_DELETE", - default=settings.MAX_ONGOING_MUTATIONS_FOR_DELETE, + max_ongoing_mutations = get_int_option( + "MAX_ONGOING_MUTATIONS_FOR_DELETE", settings.MAX_ONGOING_MUTATIONS_FOR_DELETE ) assert max_ongoing_mutations if ongoing_mutations > max_ongoing_mutations: @@ -224,7 +223,7 @@ def _num_parts_currently_mutating(cluster: ClickhouseCluster) -> int: def deletes_are_enabled() -> bool: - return bool(get_config("storage_deletes_enabled", 1)) + return get_bool_option("storage_deletes_enabled", True) def _get_rows_to_delete(storage_key: StorageKey, select_query_to_count_rows: Query) -> int: @@ -291,10 +290,7 @@ def get_new_from_clause() -> Table: if rows_to_delete == 0: raise NoRowsToDeleteException max_rows_allowed = get_storage(storage_key).get_deletion_settings().max_rows_to_delete - if ( - get_int_config("enforce_max_rows_to_delete", default=1) - and rows_to_delete > max_rows_allowed - ): + if get_bool_option("enforce_max_rows_to_delete", True) and rows_to_delete > max_rows_allowed: raise TooManyDeleteRowsException( f"Too many rows to delete ({rows_to_delete}), maximum allowed is {max_rows_allowed}" ) diff --git a/snuba/web/rpc/__init__.py b/snuba/web/rpc/__init__.py index 8846525a960..8fb3c06bb13 100644 --- a/snuba/web/rpc/__init__.py +++ b/snuba/web/rpc/__init__.py @@ -13,8 +13,9 @@ from sentry_protos.snuba.v1.error_pb2 import Error as ErrorProto from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta, TraceItemType -from snuba import environment, state +from snuba import environment from snuba.query.allocation_policies import AllocationPolicyViolations +from snuba.state.sentry_options import get_float_option from snuba.utils.metrics.backends.abstract import MetricsBackend from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper @@ -55,9 +56,7 @@ def _should_log_rpc_request() -> bool: """ Determine if this RPC request should be logged based on runtime configuration. """ - sample_rate = state.get_float_config("rpc_logging_sample_rate", 0) - if sample_rate is None: - sample_rate = 0 + sample_rate = get_float_option("rpc_logging_sample_rate", 0.0) # If sample rate is 0, never log if sample_rate <= 0.0: @@ -331,7 +330,7 @@ def _before_execute(self, in_msg: Tin) -> None: f"RPC request started - endpoint: {self.__class__.__name__}, request_id: {request_id}" ) - flush_logs = state.get_float_config("rpc_logging_flush_logs", 0) + flush_logs = get_float_option("rpc_logging_flush_logs", 0.0) if flush_logs and flush_logs > 0: _flush_logs() @@ -389,7 +388,7 @@ def _after_execute(self, in_msg: Tin, out_msg: Tout, error: Exception | None) -> logging.info( f"RPC request finished - endpoint: {self.__class__.__name__}, request_id: {request_id}, status: {status}" ) - flush_logs = state.get_float_config("rpc_logging_flush_logs", 0) + flush_logs = get_float_option("rpc_logging_flush_logs", 0.0) if flush_logs and flush_logs > 0: _flush_logs() diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index d1799a3d83a..e554d7f2a48 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -1,7 +1,7 @@ import json import math from datetime import datetime, timedelta, timezone -from typing import Any, Callable, TypeVar, cast +from typing import Any, Callable, TypeVar from google.protobuf.message import Message as ProtobufMessage from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta @@ -12,7 +12,7 @@ TraceItemFilter, ) -from snuba import settings, state +from snuba import settings from snuba.clickhouse import DATETIME_FORMAT from snuba.protos.common import ( ATTRIBUTES_TO_COALESCE, @@ -48,6 +48,7 @@ Lambda, SubscriptableReference, ) +from snuba.state.sentry_options import get_bool_option, get_int_option from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException @@ -244,12 +245,9 @@ def use_sampling_factor(meta: RequestMeta) -> bool: """ Since we started writing the sampling factor on a specific date, we should only use it on queries that start after that date. """ - use_sampling_factor_timestamp_seconds = cast( - int, - state.get_int_config( - "use_sampling_factor_timestamp_seconds", - settings.USE_SAMPLING_FACTOR_TIMESTAMP_SECONDS, - ), + use_sampling_factor_timestamp_seconds = get_int_option( + "use_sampling_factor_timestamp_seconds", + settings.USE_SAMPLING_FACTOR_TIMESTAMP_SECONDS, ) if use_sampling_factor_timestamp_seconds == 0: return False @@ -265,12 +263,9 @@ def use_array_map_columns(meta: RequestMeta) -> bool: only exists in the legacy ``attributes_array`` JSON column. A config value of 0 disables the typed-column read path entirely. """ - use_array_map_columns_timestamp_seconds = cast( - int, - state.get_int_config( - "use_array_map_columns_timestamp_seconds", - settings.USE_ARRAY_MAP_COLUMNS_TIMESTAMP_SECONDS, - ), + use_array_map_columns_timestamp_seconds = get_int_option( + "use_array_map_columns_timestamp_seconds", + settings.USE_ARRAY_MAP_COLUMNS_TIMESTAMP_SECONDS, ) if use_array_map_columns_timestamp_seconds == 0: return False @@ -1138,7 +1133,7 @@ def trace_item_filters_to_expression( ) if item_filter.HasField("any_attribute_filter"): - if not state.get_int_config("enable_any_attribute_filter", 1): + if not get_bool_option("enable_any_attribute_filter", True): return literal(True) return _any_attribute_filter_to_expression( item_filter.any_attribute_filter, membership_as_has=membership_as_has diff --git a/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py index fe22fd1e095..f8236113fb2 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py @@ -9,7 +9,6 @@ from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import TraceItemTableRequest from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta, TraceItemType -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.clickhouse.query import Expression @@ -25,6 +24,7 @@ from snuba.query.logical import Query from snuba.query.query_settings import OutcomesQuerySettings from snuba.request import Request as SnubaRequest +from snuba.state.sentry_options import get_bool_option, get_mapped_int_option from snuba.web.query import run_query from snuba.web.rpc.common.common import ( timestamp_in_range_condition, @@ -215,8 +215,9 @@ def _get_max_items_before_downsampling(self, organization_id: int) -> int: default = 1_000_000_000 return ( - state.get_int_config( - f"{self.class_name()}.max_items_before_downsampling", + get_mapped_int_option( + "storage_routing_max_items_before_downsampling", + self.class_name(), default, ) or default @@ -225,8 +226,9 @@ def _get_max_items_before_downsampling(self, organization_id: int) -> int: def _get_min_timerange_to_query_outcomes(self) -> int: default = 3600 * 4 return ( - state.get_int_config( - f"{self.class_name()}.min_timerange_to_query_outcomes", + get_mapped_int_option( + "storage_routing_min_timerange_to_query_outcomes", + self.class_name(), default, ) or default @@ -245,7 +247,7 @@ def _update_routing_decision( older_than_thirty_days = thirty_one_days_ago_ts > in_msg_meta.start_timestamp.seconds if ( - state.get_int_config("enable_long_term_retention_downsampling", 0) + get_bool_option("enable_long_term_retention_downsampling", False) and older_than_thirty_days and in_msg_meta.trace_item_type not in ITEM_TYPE_FULL_RETENTION ): diff --git a/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py index 648a8ab981c..c2c3b45dfb5 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py @@ -27,7 +27,7 @@ from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import TraceItemTableRequest from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta -from snuba import environment, settings, state +from snuba import environment, settings from snuba.configs.configuration import ( ConfigurableComponent, ConfigurableComponentData, @@ -52,6 +52,11 @@ from snuba.query.allocation_policies.utils import get_max_bytes_to_read from snuba.query.query_settings import HTTPQuerySettings from snuba.state import record_query +from snuba.state.sentry_options import ( + get_bool_option, + get_int_option, + get_mapped_int_option, +) from snuba.utils.metrics.timer import Timer from snuba.utils.metrics.wrapper import MetricsWrapper from snuba.utils.registered_class import import_submodules_in_directory @@ -315,7 +320,7 @@ def _get_default_config_definitions(self) -> list[Configuration]: return cast(list[Configuration], self._default_config_definitions) def _get_default_routing_decision_tier(self) -> Tier: - tier_int = state.get_int_config("default_tier", 1) + tier_int = get_int_option("default_tier", 1) if tier_int == 512: return Tier.TIER_512 @@ -509,7 +514,7 @@ def get_routing_decision(self, routing_context: RoutingContext) -> RoutingDecisi routing_context.cluster_load_info = ( get_cluster_loadinfo() - if state.get_config("storage_routing.enable_get_cluster_loadinfo", False) + if get_bool_option("storage_routing.enable_get_cluster_loadinfo", False) else None ) @@ -618,12 +623,17 @@ def _output_metrics(self, routing_context: RoutingContext) -> None: pass def _get_sampled_too_low_threshold(self) -> int: + # Per-strategy override, falling back to the global "StorageRouting" + # default, then the constant. The dict is keyed by routing-strategy class + # name (or DEFAULT_STORAGE_ROUTING_CONFIG_PREFIX for the global value). default = 1000 return ( - state.get_int_config( - f"{self.class_name()}.sampled_too_low_threshold", - state.get_int_config( - f"{DEFAULT_STORAGE_ROUTING_CONFIG_PREFIX}.sampled_too_low_threshold", + get_mapped_int_option( + "storage_routing_sampled_too_low_threshold", + self.class_name(), + get_mapped_int_option( + "storage_routing_sampled_too_low_threshold", + DEFAULT_STORAGE_ROUTING_CONFIG_PREFIX, default, ) or default, @@ -638,10 +648,12 @@ def _get_time_budget_ms(self) -> int: """ default = 8000 return ( - state.get_int_config( - f"{self.class_name()}.time_budget_ms", - state.get_int_config( - f"{DEFAULT_STORAGE_ROUTING_CONFIG_PREFIX}.time_budget_ms", + get_mapped_int_option( + "storage_routing_time_budget_ms", + self.class_name(), + get_mapped_int_option( + "storage_routing_time_budget_ms", + DEFAULT_STORAGE_ROUTING_CONFIG_PREFIX, default, ) or default, diff --git a/snuba/web/rpc/storage_routing/routing_strategy_selector.py b/snuba/web/rpc/storage_routing/routing_strategy_selector.py index d3d20c75035..bff849c7a19 100644 --- a/snuba/web/rpc/storage_routing/routing_strategy_selector.py +++ b/snuba/web/rpc/storage_routing/routing_strategy_selector.py @@ -8,7 +8,7 @@ from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig from snuba import settings -from snuba.state import get_config +from snuba.state.sentry_options import get_str_option from snuba.web.rpc.storage_routing.common import extract_message_meta from snuba.web.rpc.storage_routing.routing_strategies.outcomes_based import ( OutcomesBasedRoutingStrategy, @@ -89,11 +89,11 @@ def get_storage_routing_config(self, in_msg: ProtobufMessage) -> StorageRoutingC in_msg_meta = extract_message_meta(in_msg) organization_id = str(in_msg_meta.organization_id) try: - overrides = json.loads(str(get_config(_STORAGE_ROUTING_CONFIG_OVERRIDE_KEY, "{}"))) + overrides = json.loads(get_str_option(_STORAGE_ROUTING_CONFIG_OVERRIDE_KEY, "{}")) if organization_id in overrides.keys(): return StorageRoutingConfig.from_json(overrides[organization_id]) - config = str(get_config(_DEFAULT_STORAGE_ROUTING_CONFIG_KEY, "{}")) + config = get_str_option(_DEFAULT_STORAGE_ROUTING_CONFIG_KEY, "{}") return StorageRoutingConfig.from_json(json.loads(config)) except Exception as e: sentry_sdk.capture_message(f"Error getting storage routing config: {e}") diff --git a/snuba/web/rpc/v1/endpoint_export_trace_items.py b/snuba/web/rpc/v1/endpoint_export_trace_items.py index 471047922e0..bd093200c68 100644 --- a/snuba/web/rpc/v1/endpoint_export_trace_items.py +++ b/snuba/web/rpc/v1/endpoint_export_trace_items.py @@ -19,7 +19,6 @@ ) from sentry_protos.snuba.v1.trace_item_pb2 import AnyValue, ArrayValue, TraceItem -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.datasets.entities.entity_key import EntityKey @@ -32,6 +31,7 @@ from snuba.query.logical import Query from snuba.query.query_settings import HTTPQuerySettings from snuba.request import Request as SnubaRequest +from snuba.state.sentry_options import get_int_option from snuba.web.query import run_query from snuba.web.rpc import RPCEndpoint from snuba.web.rpc.common.common import ( @@ -502,7 +502,7 @@ def response_class(cls) -> Type[ExportTraceItemsResponse]: def _execute(self, in_msg: ExportTraceItemsRequest) -> ExportTraceItemsResponse: default_page_size = ( - state.get_int_config("export_trace_items_default_page_size", _DEFAULT_PAGE_SIZE) + get_int_option("export_trace_items_default_page_size", _DEFAULT_PAGE_SIZE) or _DEFAULT_PAGE_SIZE ) if in_msg.limit > 0: diff --git a/snuba/web/rpc/v1/endpoint_get_trace.py b/snuba/web/rpc/v1/endpoint_get_trace.py index 411883bdca2..aa8cb1d1813 100644 --- a/snuba/web/rpc/v1/endpoint_get_trace.py +++ b/snuba/web/rpc/v1/endpoint_get_trace.py @@ -23,7 +23,6 @@ TraceItemFilter, ) -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.datasets.entities.entity_key import EntityKey @@ -41,6 +40,7 @@ ENABLE_TRACE_PAGINATION_DEFAULT, ENDPOINT_GET_TRACE_PAGINATION_MAX_ITEMS, ) +from snuba.state.sentry_options import get_bool_option, get_float_option from snuba.utils.metrics.util import with_span from snuba.web.query import run_query from snuba.web.rpc import RPCEndpoint @@ -297,7 +297,7 @@ def _build_query( expression=column("item_id"), ), ] - if state.get_int_config("enable_trace_pagination", ENABLE_TRACE_PAGINATION_DEFAULT): + if get_bool_option("enable_trace_pagination", bool(ENABLE_TRACE_PAGINATION_DEFAULT)): order_by = new_order_by else: order_by = old_order_by @@ -337,7 +337,7 @@ def _build_query( def _get_apply_final_rollout_percentage() -> float: return ( - state.get_float_config( + get_float_option( APPLY_FINAL_ROLLOUT_PERCENTAGE_CONFIG_KEY, 0.0, ) @@ -592,8 +592,8 @@ def _execute(self, in_msg: GetTraceRequest) -> GetTraceResponse: "eap_trace_request_without_limit", 1, tags={"referrer": in_msg.meta.referrer} ) - enable_pagination = state.get_int_config( - "enable_trace_pagination", ENABLE_TRACE_PAGINATION_DEFAULT + enable_pagination = get_bool_option( + "enable_trace_pagination", bool(ENABLE_TRACE_PAGINATION_DEFAULT) ) if enable_pagination: limit = _get_pagination_limit(in_msg.limit) diff --git a/snuba/web/rpc/v1/endpoint_get_traces.py b/snuba/web/rpc/v1/endpoint_get_traces.py index 7be2e8a4774..c5ef54a991d 100644 --- a/snuba/web/rpc/v1/endpoint_get_traces.py +++ b/snuba/web/rpc/v1/endpoint_get_traces.py @@ -17,7 +17,6 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey, AttributeValue from sentry_protos.snuba.v1.trace_item_filter_pb2 import AndFilter, TraceItemFilter -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.datasets.entities.entity_key import EntityKey @@ -39,6 +38,7 @@ from snuba.query.logical import Query from snuba.query.query_settings import HTTPQuerySettings, QuerySettings from snuba.request import Request as SnubaRequest +from snuba.state.sentry_options import get_bool_option from snuba.web.query import run_query from snuba.web.rpc import RPCEndpoint from snuba.web.rpc.common.common import ( @@ -506,7 +506,7 @@ def _execute(self, in_msg: GetTracesRequest) -> GetTracesResponse: _validate_order_by(in_msg) # Feature flag: Use cross-item query path for all queries (single-item and cross-item) - use_cross_item_path = self._is_cross_event_query(in_msg.filters) or state.get_config( + use_cross_item_path = self._is_cross_event_query(in_msg.filters) or get_bool_option( "use_cross_item_path_for_single_item_queries", False ) diff --git a/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_time_series.py b/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_time_series.py index d0c3b34b54e..22c65df9fd2 100644 --- a/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_time_series.py +++ b/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_time_series.py @@ -22,7 +22,6 @@ ExtrapolationMode, ) -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.datasets.entities.entity_key import EntityKey @@ -37,6 +36,7 @@ from snuba.query.logical import Query from snuba.query.query_settings import HTTPQuerySettings from snuba.request import Request as SnubaRequest +from snuba.state.sentry_options import get_bool_option from snuba.utils.metrics.timer import Timer from snuba.web.query import run_query from snuba.web.rpc.common.common import ( @@ -498,7 +498,7 @@ def resolve( assert len(in_msg.aggregations) == 0 # aggregation is deprecated, it gets converted to conditional_aggregation - if state.get_int_config("aggregation_deprecation_enabled", 1): + if get_bool_option("aggregation_deprecation_enabled", True): for expr in in_msg.expressions: if expr.WhichOneof("expression") == "aggregation": raise RuntimeError( @@ -510,8 +510,8 @@ def resolve( routing_decision.strategy.merge_clickhouse_settings(routing_decision, query_settings) # When trace_filters are present and the feature is enabled, don't use sampling on the outer query # The inner query (getting trace IDs) will use sampling - cross_item_queries_no_sample_outer = state.get_int_config( - "cross_item_queries_no_sample_outer", 1 + cross_item_queries_no_sample_outer = get_bool_option( + "cross_item_queries_no_sample_outer", True ) if not (in_msg.trace_filters and cross_item_queries_no_sample_outer): query_settings.set_sampling_tier(routing_decision.tier) diff --git a/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_trace_item_table.py b/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_trace_item_table.py index a5c86f7813f..17d5349ee80 100644 --- a/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_trace_item_table.py +++ b/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_trace_item_table.py @@ -24,7 +24,6 @@ VirtualColumnContext, ) -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.datasets.entities.entity_key import EntityKey @@ -52,6 +51,7 @@ from snuba.query.logical import Query from snuba.query.query_settings import HTTPQuerySettings from snuba.request import Request as SnubaRequest +from snuba.state.sentry_options import get_bool_option from snuba.utils.metrics.timer import Timer from snuba.web.query import run_query from snuba.web.rpc.common.common import ( @@ -759,8 +759,8 @@ def resolve( routing_decision.strategy.merge_clickhouse_settings(routing_decision, query_settings) # When trace_filters are present and the feature is enabled, don't use sampling on the outer query # The inner query (getting trace IDs) will use sampling - cross_item_queries_no_sample_outer = state.get_int_config( - "cross_item_queries_no_sample_outer", 1 + cross_item_queries_no_sample_outer = get_bool_option( + "cross_item_queries_no_sample_outer", True ) if not (in_msg.trace_filters and cross_item_queries_no_sample_outer): query_settings.set_sampling_tier(routing_decision.tier) diff --git a/snuba/web/rpc/v1/resolvers/common/cross_item_queries.py b/snuba/web/rpc/v1/resolvers/common/cross_item_queries.py index ca5ac8fafe5..2148f76410c 100644 --- a/snuba/web/rpc/v1/resolvers/common/cross_item_queries.py +++ b/snuba/web/rpc/v1/resolvers/common/cross_item_queries.py @@ -8,7 +8,6 @@ TraceItemFilterWithType, ) -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.datasets.entities.entity_key import EntityKey @@ -22,6 +21,7 @@ from snuba.query.logical import Query from snuba.query.query_settings import HTTPQuerySettings from snuba.request import Request as SnubaRequest +from snuba.state.sentry_options import get_int_option from snuba.utils.metrics.timer import Timer from snuba.web import QueryResult from snuba.web.query import run_query @@ -156,7 +156,7 @@ def get_trace_ids_sql_for_cross_item_query( expression=f.max(column("timestamp")), ), ], - limit=limit or state.get_config("trace_ids_cross_item_query_limit", _TRACE_LIMIT), + limit=limit or get_int_option("trace_ids_cross_item_query_limit", _TRACE_LIMIT), ) treeify_or_and_conditions(query) diff --git a/snuba/web/rpc/v1/visitors/time_series_request_visitor.py b/snuba/web/rpc/v1/visitors/time_series_request_visitor.py index b46318c58cc..0368c8ccf44 100644 --- a/snuba/web/rpc/v1/visitors/time_series_request_visitor.py +++ b/snuba/web/rpc/v1/visitors/time_series_request_visitor.py @@ -15,7 +15,7 @@ ) from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter -from snuba.state import get_config +from snuba.state.sentry_options import get_bool_option from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException from snuba.web.rpc.v1.visitors.trace_item_table_request_visitor import ( NormalizeFormulaLabelsVisitor, @@ -127,7 +127,7 @@ def visit_TraceItemFilter(self, node: TraceItemFilter) -> None: elif node.HasField("comparison_filter"): k = node.comparison_filter.key if k.name == "sentry.timestamp" and k.type == AttributeKey.TYPE_STRING: - if get_config("eap.reject_string_timestamp_filters", 1): + if get_bool_option("eap.reject_string_timestamp_filters", True): raise BadSnubaRPCRequestException( "sentry.timestamp can only be compared to TYPE_INT or TYPE_DOUBLE, got TYPE_STRING" ) diff --git a/tests/admin/clickhouse/test_querylog.py b/tests/admin/clickhouse/test_querylog.py index b5d998ccdfb..ab71e84e0b3 100644 --- a/tests/admin/clickhouse/test_querylog.py +++ b/tests/admin/clickhouse/test_querylog.py @@ -1,15 +1,9 @@ from __future__ import annotations -from typing import Type - import pytest +from sentry_options.testing import override_options -from snuba import state -from snuba.admin.clickhouse.querylog import ( - _MAX_CH_THREADS, - BadThreadsValue, - _get_clickhouse_threads, -) +from snuba.admin.clickhouse.querylog import _MAX_CH_THREADS, _get_clickhouse_threads @pytest.mark.parametrize( @@ -20,19 +14,6 @@ ], ) @pytest.mark.redis_db -def test_get_clickhouse_threads(config_val: str | int, expected_threads: int) -> None: - state.set_config("admin.querylog_threads", str(config_val)) - assert _get_clickhouse_threads() == expected_threads - - -@pytest.mark.parametrize( - "config_val, error", - [ - pytest.param("invalid_value", BadThreadsValue, id="invalid_value"), - ], -) -@pytest.mark.redis_db -def test_get_clickhouse_threads_error(config_val: str | int, error: Type[Exception]) -> None: - state.set_config("admin.querylog_threads", str(config_val)) - with pytest.raises(error): - _get_clickhouse_threads() +def test_get_clickhouse_threads(config_val: int, expected_threads: int) -> None: + with override_options("snuba", {"admin.querylog_threads": config_val}): + assert _get_clickhouse_threads() == expected_threads diff --git a/tests/clickhouse/test_native.py b/tests/clickhouse/test_native.py index 1eabd3518f1..30405baed4e 100644 --- a/tests/clickhouse/test_native.py +++ b/tests/clickhouse/test_native.py @@ -6,6 +6,7 @@ import pytest from clickhouse_driver import errors from dateutil.tz import tz +from sentry_options.testing import override_options from snuba import state from snuba.clickhouse.errors import ClickhouseError @@ -56,12 +57,11 @@ class TestConcurrentError(errors.Error): # type: ignore @pytest.mark.skip(reason="broke all of a sudden, blocking CI but not critical") @pytest.mark.redis_db +@override_options("snuba", {"simultaneous_queries_sleep_seconds": 1}) def test_concurrency_limit() -> None: connection = mock.Mock() connection.execute.side_effect = TestError("some error") - state.set_config("simultaneous_queries_sleep_seconds", 0.5) - pool = ClickhousePool("host", 100, "test", "test", "test") pool.pool = queue.LifoQueue(1) pool.pool.put(connection, block=False) diff --git a/tests/conftest.py b/tests/conftest.py index 48dfe33c5c9..0a6989dab18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,17 @@ def pytest_configure() -> None: """ assert settings.TESTING, "settings.TESTING is False, try `SNUBA_SETTINGS=test` or `make test`" + # Point sentry-options at the in-repo schemas so init() reads the committed + # schema regardless of how tests are launched. This must *override* any + # inherited value rather than setdefault: the Docker image sets + # SENTRY_OPTIONS_DIR=/etc/sentry-options (where production values are + # mounted), which ships no schemas, so a setdefault() would be a no-op in + # the test container and sentry_options.init() would fail with a SchemaError + # (leaving the client uninitialized and breaking every override_options test). + os.environ["SENTRY_OPTIONS_DIR"] = os.path.join( + os.path.dirname(__file__), os.pardir, "sentry-options" + ) + initialize_snuba() setup_sentry() initialize_snuba() diff --git a/tests/datasets/entities/storage_selectors/test_eap_items.py b/tests/datasets/entities/storage_selectors/test_eap_items.py index 7034226823c..5f8e9c597a7 100644 --- a/tests/datasets/entities/storage_selectors/test_eap_items.py +++ b/tests/datasets/entities/storage_selectors/test_eap_items.py @@ -1,6 +1,6 @@ import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.datasets.entities.storage_selectors.eap_items import EAPItemsStorageSelector @@ -43,48 +43,39 @@ def test_selects_correct_eap_items_tier() -> None: @pytest.mark.redis_db +@override_options("snuba", {"enable_eap_readonly_table": True}) def test_selects_eap_items_ro_when_enabled() -> None: unimportant_query = Query(from_clause=EAP_ITEMS_ENTITY) query_settings = HTTPQuerySettings() query_settings.set_sampling_tier(Tier.TIER_1) - state.set_config("enable_eap_readonly_table", 1) - try: - selected_storage = EAPItemsStorageSelector().select_storage( - unimportant_query, query_settings, EAP_ITEMS_STORAGE_CONNECTIONS - ) - assert selected_storage.storage == get_storage(StorageKey.EAP_ITEMS_RO) - finally: - state.delete_config("enable_eap_readonly_table") + selected_storage = EAPItemsStorageSelector().select_storage( + unimportant_query, query_settings, EAP_ITEMS_STORAGE_CONNECTIONS + ) + assert selected_storage.storage == get_storage(StorageKey.EAP_ITEMS_RO) @pytest.mark.redis_db +@override_options("snuba", {"enable_eap_readonly_table": True}) def test_selects_writable_when_consistent() -> None: unimportant_query = Query(from_clause=EAP_ITEMS_ENTITY) query_settings = HTTPQuerySettings(consistent=True) query_settings.set_sampling_tier(Tier.TIER_1) - state.set_config("enable_eap_readonly_table", 1) - try: - selected_storage = EAPItemsStorageSelector().select_storage( - unimportant_query, query_settings, EAP_ITEMS_STORAGE_CONNECTIONS - ) - assert selected_storage.storage == get_storage(StorageKey.EAP_ITEMS) - finally: - state.delete_config("enable_eap_readonly_table") + selected_storage = EAPItemsStorageSelector().select_storage( + unimportant_query, query_settings, EAP_ITEMS_STORAGE_CONNECTIONS + ) + assert selected_storage.storage == get_storage(StorageKey.EAP_ITEMS) @pytest.mark.redis_db +@override_options("snuba", {"enable_eap_readonly_table": True}) def test_selects_downsample_ro_when_enabled() -> None: unimportant_query = Query(from_clause=EAP_ITEMS_ENTITY) query_settings = HTTPQuerySettings() query_settings.set_sampling_tier(Tier.TIER_512) - state.set_config("enable_eap_readonly_table", 1) - try: - selected_storage = EAPItemsStorageSelector().select_storage( - unimportant_query, query_settings, EAP_ITEMS_STORAGE_CONNECTIONS - ) - assert selected_storage.storage == get_storage(StorageKey.EAP_ITEMS_DOWNSAMPLE_512_RO) - finally: - state.delete_config("enable_eap_readonly_table") + selected_storage = EAPItemsStorageSelector().select_storage( + unimportant_query, query_settings, EAP_ITEMS_STORAGE_CONNECTIONS + ) + assert selected_storage.storage == get_storage(StorageKey.EAP_ITEMS_DOWNSAMPLE_512_RO) diff --git a/tests/datasets/entities/storage_selectors/test_errors.py b/tests/datasets/entities/storage_selectors/test_errors.py index 570e133b463..285d4c7d864 100644 --- a/tests/datasets/entities/storage_selectors/test_errors.py +++ b/tests/datasets/entities/storage_selectors/test_errors.py @@ -1,8 +1,8 @@ from typing import List import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.clickhouse.translators.snuba.mappers import ( ColumnToColumn, ColumnToIPAddress, @@ -109,15 +109,14 @@ def test_query_storage_selector( use_readable: bool, expected_storage: Storage, ) -> None: - state.set_config("enable_events_readonly_table", use_readable) - - query = parse_snql_query(str(snql_query), dataset) - assert isinstance(query, Query) + with override_options("snuba", {"enable_events_readonly_table": use_readable}): + query = parse_snql_query(str(snql_query), dataset) + assert isinstance(query, Query) - selected_storage = selector.select_storage( - query, HTTPQuerySettings(referrer="r"), storage_connections - ) - assert selected_storage.storage == expected_storage + selected_storage = selector.select_storage( + query, HTTPQuerySettings(referrer="r"), storage_connections + ) + assert selected_storage.storage == expected_storage def test_assert_raises() -> None: diff --git a/tests/datasets/plans/test_cluster_selector.py b/tests/datasets/plans/test_cluster_selector.py index 19db631660d..d40b33b1cb7 100644 --- a/tests/datasets/plans/test_cluster_selector.py +++ b/tests/datasets/plans/test_cluster_selector.py @@ -3,6 +3,8 @@ from unittest.mock import patch import pytest +from sentry_options import OptionValue +from sentry_options.testing import override_options from snuba.clusters.storage_sets import StorageSetKey from snuba.datasets.entities.entity_key import EntityKey @@ -19,7 +21,6 @@ from snuba.query.expressions import Column, Literal from snuba.query.logical import Query as LogicalQuery from snuba.query.query_settings import HTTPQuerySettings -from snuba.state import delete_config, set_config DISTS_ENTITY_KEY = EntityKey("generic_metrics_distributions") DISTS_STORAGE_KEY = StorageKey("generic_metrics_distributions") @@ -91,12 +92,14 @@ def test_column_based_partition_selector( Tests that the column based partition selector selects the right cluster for a query. """ + override: dict[str, OptionValue] = {} if set_override: logical_partition = map_org_id_to_logical_partition(org_id) - set_config( - f"{MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX}_generic_metrics_distributions", - f"[{logical_partition}]", - ) + override = { + MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX: { + "generic_metrics_distributions": f"[{logical_partition}]" + } + } query = LogicalQuery( QueryEntity( DISTS_ENTITY_KEY, @@ -116,11 +119,9 @@ def test_column_based_partition_selector( DISTS_STORAGE_SET_KEY, "org_id", ) - cluster = selector.select_cluster(query, settings) - - assert cluster.get_database() == expected_slice_db - if set_override: - delete_config(f"{MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX}_generic_metrics_distributions") + with override_options("snuba", override): + cluster = selector.select_cluster(query, settings) + assert cluster.get_database() == expected_slice_db mega_cluster_test_data = [ @@ -166,8 +167,10 @@ def test_should_use_mega_cluster( override_config: Optional[str], expected: bool, ) -> None: - if override_config: - set_config(f"{MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX}_{storage_set.value}", override_config) - assert _should_use_mega_cluster(storage_set, logical_partition) == expected - if override_config: - delete_config(f"MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX_{storage_set}") + override: dict[str, OptionValue] = ( + {MEGA_CLUSTER_RUNTIME_CONFIG_PREFIX: {storage_set.value: override_config}} + if override_config + else {} + ) + with override_options("snuba", override): + assert _should_use_mega_cluster(storage_set, logical_partition) == expected diff --git a/tests/datasets/storages/processors/test_replaced_groups.py b/tests/datasets/storages/processors/test_replaced_groups.py index fda74dc59bd..a0f29d0a344 100644 --- a/tests/datasets/storages/processors/test_replaced_groups.py +++ b/tests/datasets/storages/processors/test_replaced_groups.py @@ -2,8 +2,8 @@ from typing import Sequence import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.clickhouse.columns import ColumnSet from snuba.clickhouse.query import Query as ClickhouseQuery from snuba.datasets.storages.storage_key import StorageKey @@ -139,25 +139,20 @@ def test_without_turbo_with_projects_needing_final(query: ClickhouseQuery) -> No ) query_settings = HTTPQuerySettings() - PostReplacementConsistencyEnforcer( - "project_id", ReplacerState.ERRORS - ).process_query(query, query_settings) + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( + query, query_settings + ) assert query.get_condition() == build_in("project_id", [2]) assert query.get_from_clause().final assert ( - query_settings.get_clickhouse_settings()[ - "do_not_merge_across_partitions_select_final" - ] - == 1 + query_settings.get_clickhouse_settings()["do_not_merge_across_partitions_select_final"] == 1 ) @pytest.mark.redis_db def test_without_turbo_without_projects_needing_final(query: ClickhouseQuery) -> None: - PostReplacementConsistencyEnforcer("project_id", None).process_query( - query, HTTPQuerySettings() - ) + PostReplacementConsistencyEnforcer("project_id", None).process_query(query, HTTPQuerySettings()) assert query.get_condition() == build_in("project_id", [2]) assert not query.get_from_clause().final @@ -171,22 +166,22 @@ def test_remove_final_subscriptions(query: ClickhouseQuery) -> None: ReplacementType.EXCLUDE_GROUPS, # Arbitrary replacement type, no impact on tests ) - PostReplacementConsistencyEnforcer( - "project_id", ReplacerState.ERRORS - ).process_query(query, SubscriptionQuerySettings()) + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( + query, SubscriptionQuerySettings() + ) assert query.get_condition() == build_in("project_id", [2]) assert query.get_from_clause().final - state.set_config("skip_final_subscriptions_projects", "[2,3,4]") - PostReplacementConsistencyEnforcer( - "project_id", ReplacerState.ERRORS - ).process_query(query, SubscriptionQuerySettings()) - assert not query.get_from_clause().final + with override_options("snuba", {"skip_final_subscriptions_projects": "[2,3,4]"}): + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( + query, SubscriptionQuerySettings() + ) + assert not query.get_from_clause().final @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 5}) def test_not_many_groups_to_exclude(query: ClickhouseQuery) -> None: - state.set_config("max_group_ids_exclude", 5) ProjectsQueryFlags.set_project_exclude_groups( 2, [100, 101, 102], @@ -194,9 +189,9 @@ def test_not_many_groups_to_exclude(query: ClickhouseQuery) -> None: ReplacementType.EXCLUDE_GROUPS, # Arbitrary replacement type, no impact on tests ) - PostReplacementConsistencyEnforcer( - "project_id", ReplacerState.ERRORS - ).process_query(query, HTTPQuerySettings()) + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( + query, HTTPQuerySettings() + ) assert query.get_condition() == build_and( FunctionCall( @@ -221,8 +216,8 @@ def test_not_many_groups_to_exclude(query: ClickhouseQuery) -> None: @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 2}) def test_too_many_groups_to_exclude(query: ClickhouseQuery) -> None: - state.set_config("max_group_ids_exclude", 2) ProjectsQueryFlags.set_project_exclude_groups( 2, [100, 101, 102], @@ -230,21 +225,22 @@ def test_too_many_groups_to_exclude(query: ClickhouseQuery) -> None: ReplacementType.EXCLUDE_GROUPS, # Arbitrary replacement type, no impact on tests ) - PostReplacementConsistencyEnforcer( - "project_id", ReplacerState.ERRORS - ).process_query(query, HTTPQuerySettings()) + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( + query, HTTPQuerySettings() + ) assert query.get_condition() == build_in("project_id", [2]) assert query.get_from_clause().final @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 2}) def test_query_overlaps_replacements_processor( query: ClickhouseQuery, query_with_timestamp: ClickhouseQuery, query_with_future_timestamp: ClickhouseQuery, ) -> None: - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) # replacement time unknown, default to "overlaps" but no groups to exclude so shouldn't be final enforcer._set_query_final(query_with_timestamp, True) @@ -252,7 +248,6 @@ def test_query_overlaps_replacements_processor( assert not query_with_timestamp.get_from_clause().final # overlaps replacement and should be final due to too many groups to exclude - state.set_config("max_group_ids_exclude", 2) ProjectsQueryFlags.set_project_exclude_groups( 2, [100, 101, 102], @@ -275,12 +270,13 @@ def test_query_overlaps_replacements_processor( @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 2}) def test_single_no_replacements(query_with_single_group_id: ClickhouseQuery) -> None: """ Query is looking for a group that has not been replaced, but the project itself has replacements. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -290,7 +286,6 @@ def test_single_no_replacements(query_with_single_group_id: ClickhouseQuery) -> ) enforcer._set_query_final(query_with_single_group_id, True) - state.set_config("max_group_ids_exclude", 2) enforcer.process_query(query_with_single_group_id, HTTPQuerySettings()) assert query_with_single_group_id.get_condition() == build_and( @@ -300,12 +295,13 @@ def test_single_no_replacements(query_with_single_group_id: ClickhouseQuery) -> @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 2}) def test_single_too_many_exclude(query_with_single_group_id: ClickhouseQuery) -> None: """ Query is looking for a group that has been replaced, and there are too many groups to exclude. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -315,7 +311,6 @@ def test_single_too_many_exclude(query_with_single_group_id: ClickhouseQuery) -> ) enforcer._set_query_final(query_with_single_group_id, True) - state.set_config("max_group_ids_exclude", 2) enforcer.process_query(query_with_single_group_id, HTTPQuerySettings()) assert query_with_single_group_id.get_condition() == build_and( @@ -326,6 +321,7 @@ def test_single_too_many_exclude(query_with_single_group_id: ClickhouseQuery) -> @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 5}) def test_single_not_too_many_exclude( query_with_single_group_id: ClickhouseQuery, ) -> None: @@ -333,7 +329,7 @@ def test_single_not_too_many_exclude( Query is looking for a group that has been replaced, and there are not too many groups to exclude. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -343,7 +339,6 @@ def test_single_not_too_many_exclude( ) enforcer._set_query_final(query_with_single_group_id, True) - state.set_config("max_group_ids_exclude", 5) enforcer.process_query(query_with_single_group_id, HTTPQuerySettings()) assert query_with_single_group_id.get_condition() == build_and( @@ -354,6 +349,7 @@ def test_single_not_too_many_exclude( @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 5}) def test_multiple_disjoint_replaced( query_with_multiple_group_ids: ClickhouseQuery, ) -> None: @@ -361,7 +357,7 @@ def test_multiple_disjoint_replaced( Query is looking for multiple groups and there are replaced groups, but these sets of group ids are disjoint. (No queried groups have been replaced) """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -371,7 +367,6 @@ def test_multiple_disjoint_replaced( ) enforcer._set_query_final(query_with_multiple_group_ids, True) - state.set_config("max_group_ids_exclude", 5) enforcer.process_query(query_with_multiple_group_ids, HTTPQuerySettings()) assert query_with_multiple_group_ids.get_condition() == build_and( @@ -381,6 +376,7 @@ def test_multiple_disjoint_replaced( @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 5}) def test_multiple_fewer_exclude_than_queried( query_with_multiple_group_ids: ClickhouseQuery, ) -> None: @@ -388,7 +384,7 @@ def test_multiple_fewer_exclude_than_queried( Query is looking for multiple groups and there are replaced groups, but there are fewer excluded groups than queried groups. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -398,7 +394,6 @@ def test_multiple_fewer_exclude_than_queried( ) enforcer._set_query_final(query_with_multiple_group_ids, True) - state.set_config("max_group_ids_exclude", 5) enforcer.process_query(query_with_multiple_group_ids, HTTPQuerySettings()) assert query_with_multiple_group_ids.get_condition() == build_and( @@ -409,6 +404,7 @@ def test_multiple_fewer_exclude_than_queried( @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 2}) def test_multiple_too_many_excludes( query_with_multiple_group_ids: ClickhouseQuery, ) -> None: @@ -416,7 +412,7 @@ def test_multiple_too_many_excludes( Query is looking for multiple groups and there are too many groups to exclude, but there are fewer groups queried for than replaced. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -426,7 +422,6 @@ def test_multiple_too_many_excludes( ) enforcer._set_query_final(query_with_multiple_group_ids, True) - state.set_config("max_group_ids_exclude", 2) enforcer.process_query(query_with_multiple_group_ids, HTTPQuerySettings()) assert query_with_multiple_group_ids.get_condition() == build_and( @@ -438,6 +433,7 @@ def test_multiple_too_many_excludes( @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 5}) def test_multiple_not_too_many_excludes( query_with_multiple_group_ids: ClickhouseQuery, ) -> None: @@ -445,7 +441,7 @@ def test_multiple_not_too_many_excludes( Query is looking for multiple groups and there are not too many groups to exclude, but there are fewer groups queried for than replaced. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -455,7 +451,6 @@ def test_multiple_not_too_many_excludes( ) enforcer._set_query_final(query_with_multiple_group_ids, True) - state.set_config("max_group_ids_exclude", 5) enforcer.process_query(query_with_multiple_group_ids, HTTPQuerySettings()) assert query_with_multiple_group_ids.get_condition() == build_and( @@ -466,11 +461,12 @@ def test_multiple_not_too_many_excludes( @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 3}) def test_no_groups_not_too_many_excludes(query: ClickhouseQuery) -> None: """ Query has no groups, and not too many to exclude. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -480,7 +476,6 @@ def test_no_groups_not_too_many_excludes(query: ClickhouseQuery) -> None: ) enforcer._set_query_final(query, True) - state.set_config("max_group_ids_exclude", 3) enforcer.process_query(query, HTTPQuerySettings()) assert query.get_condition() == build_and( @@ -491,11 +486,12 @@ def test_no_groups_not_too_many_excludes(query: ClickhouseQuery) -> None: @pytest.mark.redis_db +@override_options("snuba", {"max_group_ids_exclude": 1}) def test_no_groups_too_many_excludes(query: ClickhouseQuery) -> None: """ Query has no groups, and too many to exclude. """ - enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS) + enforcer = PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value) ProjectsQueryFlags.set_project_exclude_groups( 2, @@ -505,7 +501,6 @@ def test_no_groups_too_many_excludes(query: ClickhouseQuery) -> None: ) enforcer._set_query_final(query, True) - state.set_config("max_group_ids_exclude", 1) enforcer.process_query(query, HTTPQuerySettings()) assert query.get_condition() == build_in("project_id", [2]) diff --git a/tests/datasets/test_errors_replacer.py b/tests/datasets/test_errors_replacer.py index b00c8dba62f..9c7413ce365 100644 --- a/tests/datasets/test_errors_replacer.py +++ b/tests/datasets/test_errors_replacer.py @@ -8,6 +8,7 @@ import simplejson as json from arroyo.backends.kafka import KafkaPayload from arroyo.types import BrokerValue, Message, Partition, Topic +from sentry_options.testing import override_options from snuba import replacer, settings from snuba.clickhouse import DATETIME_FORMAT @@ -19,7 +20,6 @@ from snuba.redis import RedisClientKey, get_redis_client from snuba.replacers import errors_replacer from snuba.settings import PAYLOAD_DATETIME_FORMAT -from snuba.state import delete_config, set_config from snuba.utils.metrics.backends.dummy import DummyMetricsBackend from tests.fixtures import get_raw_event from tests.helpers import write_unprocessed_events @@ -29,6 +29,11 @@ CONSUMER_GROUP = "consumer_group" +def _normalize_query(query: Optional[str]) -> str: + assert query is not None + return re.sub("[\n ]+", " ", query).strip() + + class BaseTest: @pytest.fixture def test_entity(self) -> Union[str, Tuple[str, str]]: @@ -54,9 +59,9 @@ def setup_method(self) -> None: # Total query time range is 24h before to 24h after now to account # for local machine time zones - self.from_time = datetime.now().replace( - minute=0, second=0, microsecond=0 - ) - timedelta(days=1) + self.from_time = datetime.now().replace(minute=0, second=0, microsecond=0) - timedelta( + days=1 + ) self.to_time = self.from_time + timedelta(days=2) @@ -92,7 +97,7 @@ def _clear_redis_and_force_merge(self) -> None: run_optimize(clickhouse, self.storage, cluster.get_database()) def _issue_count(self, project_id: int, group_id: Optional[int] = None) -> Any: - args = { + args: dict[str, Any] = { "project": [project_id], "selected_columns": [], "aggregations": [["count()", "", "count"]], @@ -111,9 +116,7 @@ def _get_group_id(self, project_id: int, event_id: str) -> Optional[int]: args = { "project": [project_id], "selected_columns": ["group_id"], - "conditions": [ - ["event_id", "=", str(uuid.UUID(event_id)).replace("-", "")] - ], + "conditions": [["event_id", "=", str(uuid.UUID(event_id)).replace("-", "")]], "from_date": self.from_time.isoformat(), "to_date": self.to_time.isoformat(), "tenant_ids": {"referrer": "r", "organization_id": 1234}, @@ -160,6 +163,7 @@ def test_delete_groups_insert(self) -> None: ) processed = self.replacer.process_message(message) + assert processed is not None self.replacer.flush_batch([processed]) assert self._issue_count(self.project_id) == [] @@ -204,6 +208,7 @@ def test_reprocessing_flow_insert(self) -> None: # the other events. Event 1 gets manually tombstoned by Sentry while # Event 2 prevails. processed = self.replacer.process_message(message) + assert processed is not None self.replacer.flush_batch([processed]) # At this point the count doesn't make any sense but we don't care. @@ -238,6 +243,7 @@ def test_reprocessing_flow_insert(self) -> None: # regular group deletion, except only a subset of events have been # tombstoned (the ones that will *not* be reprocessed). processed = self.replacer.process_message(message) + assert processed is not None self.replacer.flush_batch([processed]) # Group 2 should contain the one event that the user chose to @@ -283,6 +289,7 @@ def test_merge_insert(self) -> None: ) processed = self.replacer.process_message(message) + assert processed is not None self.replacer.flush_batch([processed]) assert self._issue_count(1) == [{"count": 1, "group_id": 2}] @@ -325,12 +332,13 @@ def test_unmerge_insert(self) -> None: ) processed = self.replacer.process_message(message) + assert processed is not None self.replacer.flush_batch([processed]) assert self._issue_count(self.project_id) == [{"count": 1, "group_id": 2}] + @override_options("snuba", {"skip_seen_offsets": True}) def test_process_offset_twice(self) -> None: - set_config("skip_seen_offsets", True) self.event["project_id"] = self.project_id self.event["group_id"] = 1 self.event["primary_hash"] = "a" * 32 @@ -349,9 +357,7 @@ def test_process_offset_twice(self) -> None: "previous_group_id": 1, "new_group_id": 2, "hashes": ["a" * 32], - "datetime": datetime.utcnow().strftime( - PAYLOAD_DATETIME_FORMAT - ), + "datetime": datetime.utcnow().strftime(PAYLOAD_DATETIME_FORMAT), }, ) ).encode("utf-8"), @@ -364,16 +370,17 @@ def test_process_offset_twice(self) -> None: ) processed = self.replacer.process_message(message) + assert processed is not None self.replacer.flush_batch([processed]) # should be None since the offset should be in Redis, indicating it should be skipped assert self.replacer.process_message(message) is None + @override_options("snuba", {"skip_seen_offsets": True}) def test_multiple_partitions(self) -> None: """ Different partitions should have independent offset checks. """ - set_config("skip_seen_offsets", True) self.event["project_id"] = self.project_id self.event["group_id"] = 1 self.event["primary_hash"] = "a" * 32 @@ -417,12 +424,13 @@ def test_multiple_partitions(self) -> None: ) processed = self.replacer.process_message(partition_one) + assert processed is not None self.replacer.flush_batch([processed]) # different partition should be unaffected even if it's the same offset assert self.replacer.process_message(partition_two) is not None + @override_options("snuba", {"skip_seen_offsets": True}) def test_reset_consumer_group_offset_check(self) -> None: - set_config("skip_seen_offsets", True) self.event["project_id"] = self.project_id self.event["group_id"] = 1 self.event["primary_hash"] = "a" * 32 @@ -441,9 +449,7 @@ def test_reset_consumer_group_offset_check(self) -> None: "previous_group_id": 1, "new_group_id": 2, "hashes": ["a" * 32], - "datetime": datetime.utcnow().strftime( - PAYLOAD_DATETIME_FORMAT - ), + "datetime": datetime.utcnow().strftime(PAYLOAD_DATETIME_FORMAT), }, ) ).encode("utf-8"), @@ -455,18 +461,19 @@ def test_reset_consumer_group_offset_check(self) -> None: ) ) - self.replacer.flush_batch([self.replacer.process_message(message)]) - - set_config(replacer.RESET_CHECK_CONFIG, f"[{CONSUMER_GROUP}]") + processed = self.replacer.process_message(message) + assert processed is not None + self.replacer.flush_batch([processed]) - # Offset to check against should be reset so this message shouldn't be skipped - assert self.replacer.process_message(message) is not None + with override_options("snuba", {replacer.RESET_CHECK_CONFIG: f"[{CONSUMER_GROUP}]"}): + # Offset to check against should be reset so this message shouldn't be skipped + assert self.replacer.process_message(message) is not None + @override_options("snuba", {"skip_seen_offsets": True}) def test_offset_already_processed(self) -> None: """ Don't process an offset that already exists in Redis. """ - set_config("skip_seen_offsets", True) self.event["project_id"] = self.project_id self.event["group_id"] = 1 self.event["primary_hash"] = "a" * 32 @@ -534,7 +541,7 @@ def test_delete_promoted_tag_process(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "all_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, flags.key, flags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, group_id, primary_hash, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, exception_main_thread, sdk_integrations, modules.name, modules.version, trace_sampled, num_processing_errors, replay_id, symbolicated_in_app, timestamp_ms, sample_weight, group_first_seen", @@ -546,12 +553,12 @@ def test_delete_promoted_tag_process(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted AND has(`tags.key`, %(tag_str)s)" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted AND has(`tags.key`, %(tag_str)s)" % query_args ) @@ -572,7 +579,7 @@ def test_delete_unpromoted_tag_process(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "all_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, flags.key, flags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, group_id, primary_hash, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, exception_main_thread, sdk_integrations, modules.name, modules.version, trace_sampled, num_processing_errors, replay_id, symbolicated_in_app, timestamp_ms, sample_weight, group_first_seen", @@ -584,22 +591,20 @@ def test_delete_unpromoted_tag_process(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted AND has(`tags.key`, %(tag_str)s)" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted AND has(`tags.key`, %(tag_str)s)" % query_args ) assert replacement.get_query_time_flags() == errors_replacer.NeedsFinal() - @pytest.mark.parametrize( - "old_primary_hash", ["e3d704f3542b44a621ebed70dc0efe13", False, None] - ) - def test_tombstone_events_process(self, old_primary_hash) -> None: + @pytest.mark.parametrize("old_primary_hash", ["e3d704f3542b44a621ebed70dc0efe13", False, None]) + def test_tombstone_events_process(self, old_primary_hash: Union[str, bool, None]) -> None: timestamp = datetime.now() message_kwargs = { "project_id": self.project_id, @@ -615,11 +620,10 @@ def test_tombstone_events_process(self, old_primary_hash) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement + assert isinstance(replacement, errors_replacer.Replacement) old_primary_condition = ( - " AND primary_hash = 'e3d704f3-542b-44a6-21eb-ed70dc0efe13'" - if old_primary_hash - else "" + " AND primary_hash = 'e3d704f3-542b-44a6-21eb-ed70dc0efe13'" if old_primary_hash else "" ) query_args = { @@ -631,12 +635,12 @@ def test_tombstone_events_process(self, old_primary_hash) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == f"SELECT count() FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s){old_primary_condition} WHERE project_id = %(project_id)s AND NOT deleted" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == f"INSERT INTO %(table_name)s (%(required_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s){old_primary_condition} WHERE project_id = %(project_id)s AND NOT deleted" % query_args ) @@ -659,7 +663,7 @@ def test_replace_group_process(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "event_ids": "'00e24a15-0d7f-4ee4-b142-b61b4d893b6d'", @@ -670,13 +674,13 @@ def test_replace_group_process(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s) WHERE project_id = %(project_id)s AND NOT deleted" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s) WHERE project_id = %(project_id)s AND NOT deleted" % query_args ) @@ -698,7 +702,7 @@ def test_replace_group_process_alternate_date(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "event_ids": "'00e24a15-0d7f-4ee4-b142-b61b4d893b6d'", @@ -709,13 +713,13 @@ def test_replace_group_process_alternate_date(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s) WHERE project_id = %(project_id)s AND NOT deleted" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s) WHERE project_id = %(project_id)s AND NOT deleted" % query_args ) @@ -737,7 +741,7 @@ def test_merge_process(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "all_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, flags.key, flags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, group_id, primary_hash, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, exception_main_thread, sdk_integrations, modules.name, modules.version, trace_sampled, num_processing_errors, replay_id, symbolicated_in_app, timestamp_ms, sample_weight, group_first_seen", @@ -749,19 +753,17 @@ def test_merge_process(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL PREWHERE group_id IN (%(previous_group_ids)s) WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE group_id IN (%(previous_group_ids)s) WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted" % query_args ) - assert replacement.get_query_time_flags() == errors_replacer.ExcludeGroups( - [1, 2] - ) + assert replacement.get_query_time_flags() == errors_replacer.ExcludeGroups([1, 2]) def test_unmerge_process(self) -> None: timestamp = datetime.now() @@ -780,6 +782,7 @@ def test_unmerge_process(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "all_columns": "project_id, timestamp, event_id, platform, environment, release, dist, ip_address_v4, ip_address_v6, user, user_id, user_name, user_email, sdk_name, sdk_version, http_method, http_referer, tags.key, tags.value, flags.key, flags.value, contexts.key, contexts.value, transaction_name, span_id, trace_id, partition, offset, message_timestamp, retention_days, deleted, group_id, primary_hash, received, message, title, culprit, level, location, version, type, exception_stacks.type, exception_stacks.value, exception_stacks.mechanism_type, exception_stacks.mechanism_handled, exception_frames.abs_path, exception_frames.colno, exception_frames.filename, exception_frames.function, exception_frames.lineno, exception_frames.in_app, exception_frames.package, exception_frames.module, exception_frames.stack_level, exception_main_thread, sdk_integrations, modules.name, modules.version, trace_sampled, num_processing_errors, replay_id, symbolicated_in_app, timestamp_ms, sample_weight, group_first_seen", @@ -792,12 +795,12 @@ def test_unmerge_process(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL PREWHERE primary_hash IN (%(hashes)s) WHERE group_id = %(previous_group_id)s AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(all_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE primary_hash IN (%(hashes)s) WHERE group_id = %(previous_group_id)s AND project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted" % query_args ) @@ -821,7 +824,7 @@ def test_tombstone_events_process_timestamp(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "event_ids": "'00e24a15-0d7f-4ee4-b142-b61b4d893b6d'", @@ -832,12 +835,12 @@ def test_tombstone_events_process_timestamp(self) -> None: } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == f"SELECT count() FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s) WHERE project_id = %(project_id)s AND NOT deleted AND timestamp >= toDateTime('{from_ts.strftime(DATETIME_FORMAT)}') AND timestamp <= toDateTime('{to_ts.strftime(DATETIME_FORMAT)}')" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == f"INSERT INTO %(table_name)s (%(required_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE event_id IN (%(event_ids)s) WHERE project_id = %(project_id)s AND NOT deleted AND timestamp >= toDateTime('{from_ts.strftime(DATETIME_FORMAT)}') AND timestamp <= toDateTime('{to_ts.strftime(DATETIME_FORMAT)}')" % query_args ) @@ -858,7 +861,7 @@ def test_delete_groups_process(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None + assert isinstance(replacement, errors_replacer.Replacement) query_args = { "group_ids": "1, 2, 3", "project_id": self.project_id, @@ -868,12 +871,12 @@ def test_delete_groups_process(self) -> None: "table_name": "foo", } assert ( - re.sub("[\n ]+", " ", replacement.get_count_query("foo")).strip() + _normalize_query(replacement.get_count_query("foo")) == "SELECT count() FROM %(table_name)s FINAL PREWHERE group_id IN (%(group_ids)s) WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted" % query_args ) assert ( - re.sub("[\n ]+", " ", replacement.get_insert_query("foo")).strip() + _normalize_query(replacement.get_insert_query("foo")) == "INSERT INTO %(table_name)s (%(required_columns)s) SELECT %(select_columns)s FROM %(table_name)s FINAL PREWHERE group_id IN (%(group_ids)s) WHERE project_id = %(project_id)s AND received <= CAST('%(timestamp)s' AS DateTime) AND NOT deleted" % query_args ) @@ -896,17 +899,19 @@ def test_project_bypass(self) -> None: meta_and_replacement = self.replacer.process_message(self._wrap(message)) assert meta_and_replacement is not None _, replacement = meta_and_replacement - assert replacement is not None - - set_config("replacements_bypass_projects", f"[{self.project_id + 1}]") - meta_and_replacement = self.replacer.process_message(self._wrap(message)) - assert meta_and_replacement is not None - _, replacement = meta_and_replacement - assert replacement is not None - - set_config( - "replacements_bypass_projects", f"[{self.project_id + 1},{self.project_id}]" - ) - meta_and_replacement = self.replacer.process_message(self._wrap(message)) - assert meta_and_replacement is None - delete_config("replacements_bypass_projects") + assert isinstance(replacement, errors_replacer.Replacement) + + with override_options( + "snuba", {"replacements_bypass_projects": f"[{self.project_id + 1}]"} + ): + meta_and_replacement = self.replacer.process_message(self._wrap(message)) + assert meta_and_replacement is not None + _, replacement = meta_and_replacement + assert isinstance(replacement, errors_replacer.Replacement) + + with override_options( + "snuba", + {"replacements_bypass_projects": f"[{self.project_id + 1},{self.project_id}]"}, + ): + meta_and_replacement = self.replacer.process_message(self._wrap(message)) + assert meta_and_replacement is None diff --git a/tests/datasets/test_events.py b/tests/datasets/test_events.py index b3e5637edfc..23acd0819cd 100644 --- a/tests/datasets/test_events.py +++ b/tests/datasets/test_events.py @@ -1,6 +1,6 @@ import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.clickhouse.columns import ColumnSet from snuba.clickhouse.translators.snuba.mapping import TranslationMappers from snuba.clusters.cluster import ClickhouseClientSettings @@ -52,9 +52,8 @@ def test_tags_hash_map(self) -> None: @pytest.mark.redis_db +@override_options("snuba", {"enable_events_readonly_table": True}) def test_storage_selector() -> None: - state.set_config("enable_events_readonly_table", True) - storage = get_storage(StorageKey.ERRORS) storage_ro = get_storage(StorageKey.ERRORS_RO) storage_connections = [ diff --git a/tests/datasets/test_transaction_processor.py b/tests/datasets/test_transaction_processor.py index c5bd2695cc3..c0e0fc01cb7 100644 --- a/tests/datasets/test_transaction_processor.py +++ b/tests/datasets/test_transaction_processor.py @@ -2,10 +2,11 @@ from copy import deepcopy from dataclasses import dataclass from datetime import datetime, timedelta, timezone -from typing import Any, Mapping, Optional, Sequence, Tuple +from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence, Tuple from unittest.mock import ANY import pytest +from sentry_options.testing import override_options from snuba import settings from snuba.consumers.types import KafkaMessageMetadata @@ -13,7 +14,6 @@ TransactionsMessageProcessor, ) from snuba.processor import InsertBatch -from snuba.state import set_config @dataclass @@ -51,7 +51,7 @@ class TransactionEvent: received: Optional[float] = None def get_trace_context(self) -> Optional[Mapping[str, Any]]: - context = { + context: Dict[str, Any] = { "sampled": True, "trace_id": self.trace_id, "op": self.op, @@ -93,7 +93,7 @@ def get_replay_context(self) -> Optional[Mapping[str, str]]: return None return {"replay_id": self.replay_id} - def serialize(self) -> Tuple[int, str, Mapping[str, Any]]: + def serialize(self) -> Tuple[int, str, Dict[str, Any]]: return ( 2, "insert", @@ -217,15 +217,13 @@ def serialize(self) -> Tuple[int, str, Mapping[str, Any]]: }, ) - def build_result(self, meta: KafkaMessageMetadata) -> Mapping[str, Any]: + def build_result(self, meta: KafkaMessageMetadata) -> MutableMapping[str, Any]: start_timestamp = datetime.utcfromtimestamp(self.start_timestamp) finish_timestamp = datetime.utcfromtimestamp(self.timestamp) - spans = sorted( - [(self.op, int("a" * 16, 16), 1.2345), ("http", int("b" * 16, 16), 0.1234)] - ) + spans = sorted([(self.op, int("a" * 16, 16), 1.2345), ("http", int("b" * 16, 16), 0.1234)]) - ret = { + ret: Dict[str, Any] = { "deleted": 0, "project_id": 1, "event_id": str(uuid.UUID(self.event_id)), @@ -240,9 +238,7 @@ def build_result(self, meta: KafkaMessageMetadata) -> Mapping[str, Any]: "start_ms": int(start_timestamp.microsecond / 1000), "finish_ts": finish_timestamp, "finish_ms": int(finish_timestamp.microsecond / 1000), - "duration": int( - (finish_timestamp - start_timestamp).total_seconds() * 1000 - ), + "duration": int((finish_timestamp - start_timestamp).total_seconds() * 1000), "platform": self.platform, "environment": self.environment, "release": self.release, @@ -334,9 +330,7 @@ def __get_transaction_event(self) -> TransactionEvent: op="navigation", timestamp=finish, start_timestamp=start, - received=( - datetime.now(tz=timezone.utc) - timedelta(seconds=15) - ).timestamp(), + received=(datetime.now(tz=timezone.utc) - timedelta(seconds=15)).timestamp(), platform="python", dist="", user_name="me", @@ -369,9 +363,7 @@ def test_skip_non_transactions(self) -> None: # Force an invalid event payload[2]["data"]["type"] = "error" - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) processor = TransactionsMessageProcessor() assert processor.process_message(payload, meta) is None @@ -381,9 +373,7 @@ def test_missing_trace_context(self) -> None: # Force an invalid event del payload[2]["data"]["contexts"] - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) processor = TransactionsMessageProcessor() assert processor.process_message(payload, meta) is None @@ -393,23 +383,19 @@ def test_base_process(self) -> None: message = self.__get_transaction_event() - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) assert TransactionsMessageProcessor().process_message( message.serialize(), meta ) == InsertBatch([message.build_result(meta)], ANY) settings.TRANSACT_SKIP_CONTEXT_STORE = old_skip_context + @override_options("snuba", {"max_spans_per_transaction": 1}) def test_too_many_spans(self) -> None: old_skip_context = settings.TRANSACT_SKIP_CONTEXT_STORE settings.TRANSACT_SKIP_CONTEXT_STORE = {1: {"experiments"}} - set_config("max_spans_per_transaction", 1) message = self.__get_transaction_event() - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) payload = message.serialize() @@ -421,9 +407,9 @@ def test_too_many_spans(self) -> None: result["spans.exclusive_time"] = [0] result["spans.exclusive_time_32"] = [1.2345] - assert TransactionsMessageProcessor().process_message( - payload, meta - ) == InsertBatch([result], ANY) + assert TransactionsMessageProcessor().process_message(payload, meta) == InsertBatch( + [result], ANY + ) settings.TRANSACT_SKIP_CONTEXT_STORE = old_skip_context def test_missing_transaction_source(self) -> None: @@ -436,23 +422,19 @@ def test_missing_transaction_source(self) -> None: # Remove transaction_info del payload_wo_transaction_info[2]["data"]["transaction_info"] - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) actual_message = TransactionsMessageProcessor().process_message( payload_wo_transaction_info, meta ) + assert isinstance(actual_message, InsertBatch) assert actual_message.rows[0]["transaction_source"] == "" # Remove transaction_info.source del payload_wo_source[2]["data"]["transaction_info"]["source"] - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) - actual_message = TransactionsMessageProcessor().process_message( - payload_wo_source, meta - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) + actual_message = TransactionsMessageProcessor().process_message(payload_wo_source, meta) + assert isinstance(actual_message, InsertBatch) assert actual_message.rows[0]["transaction_source"] == "" def test_app_ctx_none(self) -> None: @@ -462,9 +444,7 @@ def test_app_ctx_none(self) -> None: message = self.__get_transaction_event() message.has_app_ctx = False - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) assert TransactionsMessageProcessor().process_message( message.serialize(), meta ) == InsertBatch([message.build_result(meta)], ANY) @@ -478,14 +458,10 @@ def test_replay_id_as_tag(self) -> None: message = self.__get_transaction_event() payload = message.serialize() - payload[2]["data"]["tags"].append( - ["replayId", "d2731f8ed8934c6fa5253e450915aa12"] - ) + payload[2]["data"]["tags"].append(["replayId", "d2731f8ed8934c6fa5253e450915aa12"]) del payload[2]["data"]["contexts"]["replay"] - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) result = message.build_result(meta) # when the replay_id is sent as a tag instead of a context, @@ -495,9 +471,9 @@ def test_replay_id_as_tag(self) -> None: result["tags.key"].insert(1, "replayId") result["tags.value"].insert(1, "d2731f8ed8934c6fa5253e450915aa12") - assert TransactionsMessageProcessor().process_message( - payload, meta - ) == InsertBatch([result], ANY) + assert TransactionsMessageProcessor().process_message(payload, meta) == InsertBatch( + [result], ANY + ) def test_replay_id_as_tag_and_context(self) -> None: """ @@ -509,13 +485,9 @@ def test_replay_id_as_tag_and_context(self) -> None: message = self.__get_transaction_event() payload = message.serialize() - payload[2]["data"]["tags"].append( - ["replayId", "d2731f8ed8934c6fa5253e450915aa12"] - ) + payload[2]["data"]["tags"].append(["replayId", "d2731f8ed8934c6fa5253e450915aa12"]) - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) result = message.build_result(meta) # when the replay_id is sent as a tag instead of a context, @@ -525,9 +497,9 @@ def test_replay_id_as_tag_and_context(self) -> None: result["tags.key"].insert(1, "replayId") result["tags.value"].insert(1, "d2731f8ed8934c6fa5253e450915aa12") - assert TransactionsMessageProcessor().process_message( - payload, meta - ) == InsertBatch([result], ANY) + assert TransactionsMessageProcessor().process_message(payload, meta) == InsertBatch( + [result], ANY + ) def test_replay_id_as_invalid_tag(self) -> None: """ @@ -541,9 +513,7 @@ def test_replay_id_as_invalid_tag(self) -> None: del payload[2]["data"]["contexts"]["replay"] payload[2]["data"]["tags"].append(["replayId", "I_AM_NOT_A_UUID"]) - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) result = message.build_result(meta) del result["replay_id"] @@ -552,9 +522,9 @@ def test_replay_id_as_invalid_tag(self) -> None: result["tags.key"].insert(1, "replayId") result["tags.value"].insert(1, "I_AM_NOT_A_UUID") - assert TransactionsMessageProcessor().process_message( - payload, meta - ) == InsertBatch([result], ANY) + assert TransactionsMessageProcessor().process_message(payload, meta) == InsertBatch( + [result], ANY + ) def test_trace_data_is_none(self) -> None: """ @@ -566,9 +536,7 @@ def test_trace_data_is_none(self) -> None: # Force an invalid event payload[2]["data"]["contexts"]["trace"]["data"] = None - meta = KafkaMessageMetadata( - offset=1, partition=2, timestamp=datetime(1970, 1, 1) - ) + meta = KafkaMessageMetadata(offset=1, partition=2, timestamp=datetime(1970, 1, 1)) result = message.build_result(meta) diff --git a/tests/lw_deletions/test_lw_deletions.py b/tests/lw_deletions/test_lw_deletions.py index 8bb2ff33fd3..a0633c6ae14 100644 --- a/tests/lw_deletions/test_lw_deletions.py +++ b/tests/lw_deletions/test_lw_deletions.py @@ -8,8 +8,8 @@ import rapidjson from arroyo.backends.kafka import KafkaPayload from arroyo.types import BrokerValue, Message, Partition, Topic +from sentry_options.testing import override_options -from snuba import state from snuba.clusters.cluster import ClickhouseNode from snuba.datasets.storages.factory import get_writable_storage from snuba.datasets.storages.storage_key import StorageKey @@ -98,16 +98,16 @@ def test_clickhouse_settings(mock_execute: Mock, mock_num_mutations: Mock) -> No next_step=FormatQuery(commit_step, storage, SearchIssuesFormatter(), metrics), increment_by=increment_by, ) - state.set_config("lightweight_deletes_sync", 2) make_message = generate_message() - strategy.submit(next(make_message)) - strategy.submit(next(make_message)) - strategy.submit(next(make_message)) + with override_options("snuba", {"lightweight_deletes_sync": 2}): + strategy.submit(next(make_message)) + strategy.submit(next(make_message)) + strategy.submit(next(make_message)) # use different setting for second execute_query - state.set_config("lightweight_deletes_sync", 0) - strategy.submit(next(make_message)) - strategy.close() - strategy.join() + with override_options("snuba", {"lightweight_deletes_sync": 0}): + strategy.submit(next(make_message)) + strategy.close() + strategy.join() assert mock_execute.call_count == 2 assert commit_step.submit.call_count == 2 @@ -229,6 +229,7 @@ def _make_single_message( create=True, ) @pytest.mark.redis_db +@override_options("snuba", {"lw_deletes_split_by_partition": {"search_issues": 1}}) def test_split_by_partition_enabled(mock_execute: Mock, mock_num_mutations: Mock) -> None: """ When partition splitting is enabled and system.parts returns 3 Monday dates, @@ -238,8 +239,6 @@ def test_split_by_partition_enabled(mock_execute: Mock, mock_num_mutations: Mock metrics = Mock() storage = get_writable_storage(StorageKey("search_issues")) - state.set_config("lw_deletes_split_by_partition_search_issues", 1) - format_query = FormatQuery(commit_step, storage, SearchIssuesFormatter(), metrics) with ( @@ -286,9 +285,7 @@ def test_split_by_partition_disabled(mock_execute: Mock, mock_num_mutations: Moc metrics = Mock() storage = get_writable_storage(StorageKey("search_issues")) - # Ensure config is off (default) - state.set_config("lw_deletes_split_by_partition_search_issues", 0) - + # Config is off by default (no entry in the lw_deletes_split_by_partition dict). strategy = BatchStepCustom( max_batch_size=8, max_batch_time=1000, @@ -306,6 +303,7 @@ def test_split_by_partition_disabled(mock_execute: Mock, mock_num_mutations: Moc @patch("snuba.lw_deletions.strategy._num_parts_currently_mutating", return_value=1) @patch("snuba.lw_deletions.strategy._execute_query") @pytest.mark.redis_db +@override_options("snuba", {"lw_deletes_split_by_partition": {"search_issues": 1}}) def test_split_by_partition_redis_tracking(mock_execute: Mock, mock_num_mutations: Mock) -> None: """ Issue a batch with partition splitting enabled. Verify Redis SET is populated. @@ -316,8 +314,6 @@ def test_split_by_partition_redis_tracking(mock_execute: Mock, mock_num_mutation metrics = Mock() storage = get_writable_storage(StorageKey("search_issues")) - state.set_config("lw_deletes_split_by_partition_search_issues", 1) - partition_dates = ["2024-01-15", "2024-01-22"] format_query = FormatQuery(commit_step, storage, SearchIssuesFormatter(), metrics) @@ -396,6 +392,7 @@ def test_split_by_partition_redis_tracking(mock_execute: Mock, mock_num_mutation @patch("snuba.lw_deletions.strategy._num_parts_currently_mutating", return_value=1) @patch("snuba.lw_deletions.strategy._execute_query") @pytest.mark.redis_db +@override_options("snuba", {"lw_deletes_split_by_partition": {"search_issues": 1}}) def test_split_by_partition_fallback(mock_execute: Mock, mock_num_mutations: Mock) -> None: """ When partition splitting is enabled but system.parts returns no partitions, @@ -405,8 +402,6 @@ def test_split_by_partition_fallback(mock_execute: Mock, mock_num_mutations: Moc metrics = Mock() storage = get_writable_storage(StorageKey("search_issues")) - state.set_config("lw_deletes_split_by_partition_search_issues", 1) - format_query = FormatQuery(commit_step, storage, SearchIssuesFormatter(), metrics) with ( @@ -518,6 +513,7 @@ def _make_eap_message( @patch("snuba.lw_deletions.strategy._num_parts_currently_mutating", return_value=1) @patch("snuba.lw_deletions.strategy._execute_query") @pytest.mark.redis_db +@override_options("snuba", {"org_ids_delete_allowlist": "1"}) def test_allowlist_partial_batch(mock_execute: Mock, mock_num_mutations: Mock) -> None: """ Batch with 2 conditions (org 1 and org 2), allowlist = "1". @@ -528,8 +524,6 @@ def test_allowlist_partial_batch(mock_execute: Mock, mock_num_mutations: Mock) - metrics = Mock() storage = get_writable_storage(StorageKey("eap_items")) - state.set_config("org_ids_delete_allowlist", "1") - format_query = FormatQuery(commit_step, storage, EAPItemsFormatter(), metrics) # Build a batch with two messages: org 1 (allowed) and org 2 (not allowed) @@ -558,6 +552,7 @@ def test_allowlist_partial_batch(mock_execute: Mock, mock_num_mutations: Mock) - @patch("snuba.lw_deletions.strategy._num_parts_currently_mutating", return_value=1) @patch("snuba.lw_deletions.strategy._execute_query") @pytest.mark.redis_db +@override_options("snuba", {"org_ids_delete_allowlist": "999"}) def test_allowlist_all_blocked(mock_execute: Mock, mock_num_mutations: Mock) -> None: """ All conditions have unallowed org IDs. _execute_query should not be called, @@ -567,8 +562,6 @@ def test_allowlist_all_blocked(mock_execute: Mock, mock_num_mutations: Mock) -> metrics = Mock() storage = get_writable_storage(StorageKey("eap_items")) - state.set_config("org_ids_delete_allowlist", "999") - format_query = FormatQuery(commit_step, storage, EAPItemsFormatter(), metrics) msg1 = _make_eap_message(5, {"organization_id": [1], "project_id": [1]}) @@ -597,6 +590,7 @@ def test_allowlist_all_blocked(mock_execute: Mock, mock_num_mutations: Mock) -> @patch("snuba.lw_deletions.strategy._num_parts_currently_mutating", return_value=1) @patch("snuba.lw_deletions.strategy._execute_query") @pytest.mark.redis_db +@override_options("snuba", {"org_ids_delete_allowlist": "1,2"}) def test_allowlist_all_allowed(mock_execute: Mock, mock_num_mutations: Mock) -> None: """ All conditions have allowed org IDs. Normal execution, no delete_skipped. @@ -605,8 +599,6 @@ def test_allowlist_all_allowed(mock_execute: Mock, mock_num_mutations: Mock) -> metrics = Mock() storage = get_writable_storage(StorageKey("eap_items")) - state.set_config("org_ids_delete_allowlist", "1,2") - format_query = FormatQuery(commit_step, storage, EAPItemsFormatter(), metrics) msg1 = _make_eap_message(5, {"organization_id": [1], "project_id": [1]}) diff --git a/tests/lw_deletions/test_off_peak.py b/tests/lw_deletions/test_off_peak.py index 6377fa533ca..8c2a23e2eb0 100644 --- a/tests/lw_deletions/test_off_peak.py +++ b/tests/lw_deletions/test_off_peak.py @@ -1,6 +1,8 @@ from __future__ import annotations +from contextlib import contextmanager from datetime import datetime, timedelta, timezone +from typing import Generator from unittest.mock import MagicMock import pytest @@ -8,8 +10,8 @@ from arroyo.backends.kafka import KafkaPayload from arroyo.processing.strategies.abstract import MessageRejected from arroyo.types import BrokerValue, Message, Partition, Topic +from sentry_options.testing import override_options -from snuba import state from snuba.lw_deletions.off_peak import OffPeakProcessingStrategy from snuba.state import get_raw_configs @@ -54,10 +56,17 @@ def _make_strategy( return strategy, next_step, metrics -def _set_offpeak_config(start: int, end: int) -> None: - state.set_config("lw_deletions_offpeak_enabled", 1) - state.set_config("lw_deletions_offpeak_start", start) - state.set_config("lw_deletions_offpeak_end", end) +@contextmanager +def _offpeak_config(start: int, end: int) -> Generator[None, None, None]: + with override_options( + "snuba", + { + "lw_deletions_offpeak_enabled": True, + "lw_deletions_offpeak_start": start, + "lw_deletions_offpeak_end": end, + }, + ): + yield @pytest.mark.redis_db @@ -70,8 +79,8 @@ def test_messages_pass_through(self) -> None: strategy.submit(msg) next_step.submit.assert_called_once_with(msg) + @override_options("snuba", {"lw_deletions_offpeak_enabled": False}) def test_messages_pass_through_when_explicitly_disabled(self) -> None: - state.set_config("lw_deletions_offpeak_enabled", 0) strategy, next_step, _ = _make_strategy() msg = _make_message() strategy.submit(msg) @@ -83,15 +92,13 @@ class TestOffPeakSameDayWindow: """Window like 2-8 (2am to 8am UTC).""" def test_within_window_passes(self) -> None: - with time_machine.travel(_tomorrow_at(5), tick=False): - _set_offpeak_config(start=2, end=8) + with time_machine.travel(_tomorrow_at(5), tick=False), _offpeak_config(start=2, end=8): strategy, next_step, _ = _make_strategy() strategy.submit(_make_message()) next_step.submit.assert_called_once() def test_outside_window_rejects(self) -> None: - with time_machine.travel(_tomorrow_at(12), tick=False): - _set_offpeak_config(start=2, end=8) + with time_machine.travel(_tomorrow_at(12), tick=False), _offpeak_config(start=2, end=8): strategy, next_step, metrics = _make_strategy() with pytest.raises(MessageRejected): strategy.submit(_make_message()) @@ -99,15 +106,13 @@ def test_outside_window_rejects(self) -> None: metrics.increment.assert_called_with("off_peak_rejected") def test_at_start_boundary_passes(self) -> None: - with time_machine.travel(_tomorrow_at(2), tick=False): - _set_offpeak_config(start=2, end=8) + with time_machine.travel(_tomorrow_at(2), tick=False), _offpeak_config(start=2, end=8): strategy, next_step, _ = _make_strategy() strategy.submit(_make_message()) next_step.submit.assert_called_once() def test_at_end_boundary_rejects(self) -> None: - with time_machine.travel(_tomorrow_at(8), tick=False): - _set_offpeak_config(start=2, end=8) + with time_machine.travel(_tomorrow_at(8), tick=False), _offpeak_config(start=2, end=8): strategy, next_step, _ = _make_strategy() with pytest.raises(MessageRejected): strategy.submit(_make_message()) @@ -118,22 +123,19 @@ class TestOffPeakMidnightSpanningWindow: """Window like 22-6 (10pm to 6am UTC, spanning midnight).""" def test_before_midnight_passes(self) -> None: - with time_machine.travel(_tomorrow_at(23), tick=False): - _set_offpeak_config(start=22, end=6) + with time_machine.travel(_tomorrow_at(23), tick=False), _offpeak_config(start=22, end=6): strategy, next_step, _ = _make_strategy() strategy.submit(_make_message()) next_step.submit.assert_called_once() def test_after_midnight_passes(self) -> None: - with time_machine.travel(_tomorrow_at(3), tick=False): - _set_offpeak_config(start=22, end=6) + with time_machine.travel(_tomorrow_at(3), tick=False), _offpeak_config(start=22, end=6): strategy, next_step, _ = _make_strategy() strategy.submit(_make_message()) next_step.submit.assert_called_once() def test_during_day_rejects(self) -> None: - with time_machine.travel(_tomorrow_at(14), tick=False): - _set_offpeak_config(start=22, end=6) + with time_machine.travel(_tomorrow_at(14), tick=False), _offpeak_config(start=22, end=6): strategy, next_step, _ = _make_strategy() with pytest.raises(MessageRejected): strategy.submit(_make_message()) @@ -144,8 +146,7 @@ class TestOffPeakSameStartEnd: """When start == end, never off-peak (disables processing).""" def test_always_rejects(self) -> None: - with time_machine.travel(_tomorrow_at(5), tick=False): - _set_offpeak_config(start=5, end=5) + with time_machine.travel(_tomorrow_at(5), tick=False), _offpeak_config(start=5, end=5): strategy, next_step, _ = _make_strategy() with pytest.raises(MessageRejected): strategy.submit(_make_message()) diff --git a/tests/pipeline/test_execution_stage.py b/tests/pipeline/test_execution_stage.py index 0fec3519970..e6c1275c1d2 100644 --- a/tests/pipeline/test_execution_stage.py +++ b/tests/pipeline/test_execution_stage.py @@ -1,9 +1,9 @@ import uuid import pytest +from sentry_options.testing import override_options from snuba import settings as snubasettings -from snuba import state from snuba.attribution import get_app_id from snuba.attribution.attribution_info import AttributionInfo from snuba.clickhouse.columns import ColumnSet @@ -236,16 +236,15 @@ def test_max_query_size_bytes(ch_query: Query) -> None: timer = Timer("test") metadata = get_fake_metadata() - state.set_config(MAX_QUERY_SIZE_BYTES_CONFIG, 1) - - res = ExecutionStage(attinfo, query_metadata=metadata).execute( - QueryPipelineResult( - data=ch_query, - query_settings=settings, - timer=timer, - error=None, + with override_options("snuba", {MAX_QUERY_SIZE_BYTES_CONFIG: 1}): + res = ExecutionStage(attinfo, query_metadata=metadata).execute( + QueryPipelineResult( + data=ch_query, + query_settings=settings, + timer=timer, + error=None, + ) ) - ) assert res.data is None assert isinstance(res.error, QueryException) @@ -267,18 +266,22 @@ def test_disable_max_query_size_check(ch_query: Query) -> None: else "test_cluster" ) - # Lowering this should make the query too big... - state.set_config(MAX_QUERY_SIZE_BYTES_CONFIG, 1) - # Unless we disable the check for this cluster. - state.set_config(DISABLE_MAX_QUERY_SIZE_CHECK_FOR_CLUSTERS_CONFIG, cluster_name) - - res = ExecutionStage(attinfo, query_metadata=metadata).execute( - QueryPipelineResult( - data=ch_query, - query_settings=settings, - timer=timer, - error=None, + # Lowering this should make the query too big, unless we disable the check + # for this cluster. + with override_options( + "snuba", + { + MAX_QUERY_SIZE_BYTES_CONFIG: 1, + DISABLE_MAX_QUERY_SIZE_CHECK_FOR_CLUSTERS_CONFIG: cluster_name, + }, + ): + res = ExecutionStage(attinfo, query_metadata=metadata).execute( + QueryPipelineResult( + data=ch_query, + query_settings=settings, + timer=timer, + error=None, + ) ) - ) assert res.data diff --git a/tests/query/parser/validation/test_functions.py b/tests/query/parser/validation/test_functions.py index 50d4a053169..6991eabf2c2 100644 --- a/tests/query/parser/validation/test_functions.py +++ b/tests/query/parser/validation/test_functions.py @@ -4,9 +4,9 @@ from unittest.mock import MagicMock import pytest +from sentry_options.testing import override_options import snuba.query.parser.validation.functions as functions -from snuba import state from snuba.clickhouse.columns import ColumnSet from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity @@ -108,9 +108,9 @@ def test_functions( @pytest.mark.parametrize("expression, should_raise", test_expressions[:1]) @pytest.mark.redis_db +@override_options("snuba", {"function-validator.enabled": True}) def test_invalid_function_name(expression: FunctionCall, should_raise: bool) -> None: data_source = QueryEntity(EntityKey.EVENTS, ColumnSet([])) - state.set_config("function-validator.enabled", True) with pytest.raises(InvalidExpressionException): FunctionCallsValidator().validate(expression, data_source) @@ -118,9 +118,9 @@ def test_invalid_function_name(expression: FunctionCall, should_raise: bool) -> @pytest.mark.parametrize("expression, should_raise", test_expressions) @pytest.mark.redis_db +@override_options("snuba", {"function-validator.enabled": True}) def test_allowed_functions_validator(expression: FunctionCall, should_raise: bool) -> None: data_source = QueryEntity(EntityKey.EVENTS, ColumnSet([])) - state.set_config("function-validator.enabled", True) if should_raise: with pytest.raises(InvalidFunctionCall): diff --git a/tests/query/processors/test_clickhouse_settings_override.py b/tests/query/processors/test_clickhouse_settings_override.py index c5b20d9681c..2e56bf577b1 100644 --- a/tests/query/processors/test_clickhouse_settings_override.py +++ b/tests/query/processors/test_clickhouse_settings_override.py @@ -1,8 +1,8 @@ from typing import Any, MutableMapping import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.clickhouse.columns import ColumnSet, DateTime, String from snuba.clickhouse.columns import SchemaModifiers as Modifiers from snuba.clickhouse.query import Query @@ -96,11 +96,11 @@ def test_per_query_settings() -> None: @pytest.mark.redis_db +@override_options( + "snuba", + {"ignore_clickhouse_settings_override": "max_execution_time,timeout_overflow_mode"}, +) def test_ignore_clickhouse_settings_overrides() -> None: - state.set_config( - "ignore_clickhouse_settings_override", - "max_execution_time,timeout_overflow_mode", - ) query = Query( Table( "discover", diff --git a/tests/query/processors/test_mandatory_condition_enforcer.py b/tests/query/processors/test_mandatory_condition_enforcer.py index 43ef45c64ea..1a0b663e3a0 100644 --- a/tests/query/processors/test_mandatory_condition_enforcer.py +++ b/tests/query/processors/test_mandatory_condition_enforcer.py @@ -1,6 +1,7 @@ from datetime import datetime import pytest +from sentry_options.testing import override_options from snuba.clickhouse.columns import ColumnSet from snuba.clickhouse.query import Query @@ -22,7 +23,6 @@ MandatoryConditionEnforcer, ) from snuba.query.query_settings import HTTPQuerySettings -from snuba.state import set_config TABLE = Table("errors", ColumnSet([]), storage_key=StorageKey("errors")) @@ -142,8 +142,8 @@ @pytest.mark.parametrize("query, valid, org_id_enforcer", test_data) @pytest.mark.redis_db +@override_options("snuba", {"mandatory_condition_enforce": True}) def test_condition_enforcer(query: Query, valid: bool, org_id_enforcer: OrgIdEnforcer) -> None: - set_config("mandatory_condition_enforce", 1) query_settings = HTTPQuerySettings(consistent=True) processor = MandatoryConditionEnforcer([org_id_enforcer, ProjectIdEnforcer()]) if valid: diff --git a/tests/query/processors/test_mapping_optimizer.py b/tests/query/processors/test_mapping_optimizer.py index 520c89432c7..21c24d9feb9 100644 --- a/tests/query/processors/test_mapping_optimizer.py +++ b/tests/query/processors/test_mapping_optimizer.py @@ -1,4 +1,5 @@ import pytest +from sentry_options.testing import override_options from snuba.clickhouse.query import Query as ClickhouseQuery from snuba.query.conditions import ( @@ -9,7 +10,6 @@ from snuba.query.expressions import Column, Expression, FunctionCall, Literal from snuba.query.processors.physical.mapping_optimizer import MappingOptimizer from snuba.query.query_settings import HTTPQuerySettings -from snuba.state import set_config from tests.query.processors.query_builders import ( build_query, column, @@ -381,11 +381,11 @@ @pytest.mark.parametrize("query, expected_condition", TEST_CASES) @pytest.mark.redis_db +@override_options("snuba", {"tags_hash_map_enabled": True}) def test_tags_hash_map( query: ClickhouseQuery, expected_condition: Expression, ) -> None: - set_config("tags_hash_map_enabled", 1) MappingOptimizer( column_name="tags", hash_map_name="_tags_hash_map", diff --git a/tests/query/processors/test_uniq_in_select_and_having.py b/tests/query/processors/test_uniq_in_select_and_having.py index 6a9a46a3c5e..17dd57a9a1e 100644 --- a/tests/query/processors/test_uniq_in_select_and_having.py +++ b/tests/query/processors/test_uniq_in_select_and_having.py @@ -1,6 +1,8 @@ from copy import deepcopy +from typing import Optional import pytest +from sentry_options.testing import override_options from snuba.clickhouse.query import Query as ClickhouseQuery from snuba.query.expressions import Column, FunctionCall, Literal @@ -9,11 +11,10 @@ UniqInSelectAndHavingProcessor, ) from snuba.query.query_settings import HTTPQuerySettings -from snuba.state import set_config from tests.query.processors.query_builders import build_query -def uniq_expression(alias: str = None, column_name: str = "user") -> FunctionCall: +def uniq_expression(alias: Optional[str] = None, column_name: str = "user") -> FunctionCall: return FunctionCall( None, "greater", @@ -81,16 +82,16 @@ def uniq_expression(alias: str = None, column_name: str = "user") -> FunctionCal @pytest.mark.parametrize("input_query", deepcopy(INVALID_QUERY_CASES)) @pytest.mark.redis_db +@override_options("snuba", {"throw_on_uniq_select_and_having": True}) def test_invalid_uniq_queries(input_query: ClickhouseQuery) -> None: - set_config("throw_on_uniq_select_and_having", True) with pytest.raises(MismatchedAggregationException): UniqInSelectAndHavingProcessor().process_query(input_query, HTTPQuerySettings()) @pytest.mark.parametrize("input_query", deepcopy(VALID_QUERY_CASES)) @pytest.mark.redis_db +@override_options("snuba", {"throw_on_uniq_select_and_having": True}) def test_valid_uniq_queries(input_query: ClickhouseQuery) -> None: - set_config("throw_on_uniq_select_and_having", True) og_query = deepcopy(input_query) UniqInSelectAndHavingProcessor().process_query(input_query, HTTPQuerySettings()) # query should not change diff --git a/tests/query/snql/test_query_column_validation.py b/tests/query/snql/test_query_column_validation.py index 083fe53bd14..52662fe488b 100644 --- a/tests/query/snql/test_query_column_validation.py +++ b/tests/query/snql/test_query_column_validation.py @@ -2,8 +2,8 @@ from typing import Any, Generator import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.datasets.factory import get_dataset @@ -43,9 +43,7 @@ ), selected_columns=[ SelectedExpression("title", Column("_snuba_title", None, "title")), - SelectedExpression( - "count", FunctionCall("_snuba_count", "count", tuple()) - ), + SelectedExpression("count", FunctionCall("_snuba_count", "count", tuple())), ], groupby=[Column("_snuba_title", None, "title")], condition=binary_condition( @@ -120,19 +118,13 @@ selected_columns=[ SelectedExpression( "4-5", - FunctionCall( - "_snuba_4-5", "minus", (Literal(None, 4), Literal(None, 5)) - ), - ), - SelectedExpression( - "e.event_id", Column("_snuba_e.event_id", "e", "event_id") + FunctionCall("_snuba_4-5", "minus", (Literal(None, 4), Literal(None, 5))), ), + SelectedExpression("e.event_id", Column("_snuba_e.event_id", "e", "event_id")), ], condition=and_cond( and_cond( - f.equals( - column("project_id", "e", "_snuba_e.project_id"), literal(1) - ), + f.equals(column("project_id", "e", "_snuba_e.project_id"), literal(1)), f.greaterOrEquals( column("timestamp", "e", "_snuba_e.timestamp"), literal(datetime.datetime(2021, 1, 1, 0, 0)), @@ -144,9 +136,7 @@ column("timestamp", "e", "_snuba_e.timestamp"), literal(datetime.datetime(2021, 1, 3, 0, 0)), ), - f.equals( - column("project_id", "t", "_snuba_t.project_id"), literal(1) - ), + f.equals(column("project_id", "t", "_snuba_t.project_id"), literal(1)), ), and_cond( f.greaterOrEquals( @@ -324,9 +314,7 @@ ], condition=and_cond( and_cond( - f.equals( - column("project_id", None, "_snuba_project_id"), literal(1) - ), + f.equals(column("project_id", None, "_snuba_project_id"), literal(1)), f.greaterOrEquals( column("timestamp", None, "_snuba_timestamp"), literal(datetime.datetime(2021, 1, 1, 0, 0)), @@ -364,9 +352,7 @@ ], condition=and_cond( and_cond( - f.equals( - column("project_id", None, "_snuba_project_id"), literal(1) - ), + f.equals(column("project_id", None, "_snuba_project_id"), literal(1)), f.greaterOrEquals( column("timestamp", None, "_snuba_timestamp"), literal(datetime.datetime(2021, 1, 1, 0, 0)), @@ -398,19 +384,17 @@ @pytest.fixture(autouse=True) def set_configs(redis_db: None) -> Generator[None, None, None]: - old_max = state.get_config("max_days") - old_align = state.get_config("date_align_seconds") - state.set_config("max_days", 5) - state.set_config("date_align_seconds", 3600) - yield - state.set_config("max_days", old_max) - state.set_config("date_align_seconds", old_align) + with override_options("snuba", {"max_days": 5, "date_align_seconds": 3600}): + yield @pytest.mark.parametrize("query_body, expected_query", time_validation_tests) @pytest.mark.redis_db def test_entity_column_validation( - query_body: str, expected_query: LogicalQuery, set_configs: Any, monkeypatch + query_body: str, + expected_query: LogicalQuery, + set_configs: Any, + monkeypatch: pytest.MonkeyPatch, ) -> None: events = get_dataset("events") diff --git a/tests/replacer/test_cluster_replacements.py b/tests/replacer/test_cluster_replacements.py index 1eff00086ff..dd8eda0de44 100644 --- a/tests/replacer/test_cluster_replacements.py +++ b/tests/replacer/test_cluster_replacements.py @@ -15,6 +15,7 @@ ) import pytest +from sentry_options.testing import override_options from snuba.clickhouse.native import ClickhousePool from snuba.clusters import cluster @@ -189,20 +190,22 @@ def test_write_each_node( Test the execution of replacement queries on both storage nodes and query nodes. """ - set_config("write_node_replacements_global", write_node_replacements_global) - override_func = request.getfixturevalue(override_fixture) - test_cluster = override_func(True) - - replacer = ReplacerWorker( - get_writable_storage(StorageKey.ERRORS), - "consumer_group", - DummyMetricsBackend(), - ) + with override_options( + "snuba", {"write_node_replacements_global": write_node_replacements_global} + ): + override_func = request.getfixturevalue(override_fixture) + test_cluster = override_func(True) + + replacer = ReplacerWorker( + get_writable_storage(StorageKey.ERRORS), + "consumer_group", + DummyMetricsBackend(), + ) - replacer.flush_batch([(ReplacementMessageMetadata(0, 0, ""), DummyReplacement())]) + replacer.flush_batch([(ReplacementMessageMetadata(0, 0, ""), DummyReplacement())]) - queries = test_cluster.get_queries() - assert queries == expected_queries + queries = test_cluster.get_queries() + assert queries == expected_queries @pytest.mark.redis_db diff --git a/tests/replacer/test_replacements_and_expiry.py b/tests/replacer/test_replacements_and_expiry.py index 53806f5c09e..c5642520a1f 100644 --- a/tests/replacer/test_replacements_and_expiry.py +++ b/tests/replacer/test_replacements_and_expiry.py @@ -1,4 +1,3 @@ -import typing from datetime import datetime, timedelta from unittest import mock @@ -10,15 +9,13 @@ get_config_auto_replacements_bypass_projects, set_config_auto_replacements_bypass_projects, ) -from snuba.state import get_int_config +from snuba.state.sentry_options import get_int_option @freeze_time("2024-5-13 09:00:00") class TestState: start_test_time = datetime.now() - expiry_window_minutes = typing.cast( - int, get_int_config(REPLACEMENTS_EXPIRY_WINDOW_MINUTES_KEY, 5) - ) + expiry_window_minutes = get_int_option(REPLACEMENTS_EXPIRY_WINDOW_MINUTES_KEY, 5) proj1_add_time = start_test_time proj2_add_time = start_test_time + timedelta(minutes=expiry_window_minutes // 2) proj1_expiry = proj1_add_time + timedelta(minutes=expiry_window_minutes) @@ -73,7 +70,7 @@ def test_expiry_does_not_update(self) -> None: @pytest.mark.redis_db @mock.patch( - "snuba.replacers.replacements_and_expiry.get_int_config", + "snuba.replacers.replacements_and_expiry.get_int_option", ) def test_expiry_window_changes(self, mock: mock.MagicMock) -> None: mock.side_effect = [5, 10] diff --git a/tests/request/test_build_request.py b/tests/request/test_build_request.py index 5f448d0b020..e4361fe4c6e 100644 --- a/tests/request/test_build_request.py +++ b/tests/request/test_build_request.py @@ -4,8 +4,8 @@ from typing import Any, Dict import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.datasets.factory import get_dataset @@ -97,7 +97,7 @@ def test_build_request(body: Dict[str, Any], condition: Expression) -> None: assert request.referrer == "my_request" assert dict(request.original_body) == body status, differences = request.query.equals(expected_query) - assert status == True, f"Query mismatch: {differences}" + assert status, f"Query mismatch: {differences}" TENANT_ID_TESTS = [ @@ -185,8 +185,8 @@ def test_tenant_ids( @pytest.mark.redis_db +@override_options("snuba", {"snql_disabled_dataset": {"events": True}}) def test_disabled_dataset() -> None: - state.set_config("snql_disabled_dataset__events", True) dataset = get_dataset("events") schema = RequestSchema.build(HTTPQuerySettings) diff --git a/tests/state/test_cache.py b/tests/state/test_cache.py index 8d8460c4631..20c544d65cd 100644 --- a/tests/state/test_cache.py +++ b/tests/state/test_cache.py @@ -13,10 +13,10 @@ from redis import RedisError, ResponseError from redis.exceptions import ReadOnlyError from redis.exceptions import TimeoutError as RedisTimeoutError +from sentry_options.testing import override_options from sentry_redis_tools.failover_redis import FailoverRedis from snuba.redis import RedisClientKey, get_redis_client -from snuba.state import set_config from snuba.state.cache.abstract import Cache from snuba.state.cache.redis.backend import RedisCache from snuba.utils.codecs import ExceptionAwareCodec @@ -109,8 +109,8 @@ def noop(value: int) -> None: @pytest.mark.redis_db +@override_options("snuba", {"read_through_cache.short_circuit": True}) def test_short_circuit(backend: Cache[bytes]) -> None: - set_config("read_through_cache.short_circuit", 1) key = "key" value = b"value" function = mock.MagicMock(return_value=value) @@ -208,7 +208,9 @@ def worker() -> bytes: with pytest.raises(ReadThroughCustomException) as excinfo: waiter.result() - assert excinfo.value.message == "error" + # mypy infers excinfo.value as the ExceptionInfo TypeVar default rather than + # the locally-defined exception class, so .message is not visible to it. + assert excinfo.value.message == "error" # type: ignore[attr-defined] @pytest.mark.parametrize( diff --git a/tests/state/test_rate_limit.py b/tests/state/test_rate_limit.py index c0250fb4795..932c2b26ba9 100644 --- a/tests/state/test_rate_limit.py +++ b/tests/state/test_rate_limit.py @@ -2,10 +2,11 @@ import time import uuid -from typing import Any, Tuple +from typing import Any, Iterator, Tuple from unittest.mock import patch import pytest +from sentry_options.testing import override_options from snuba import state from snuba.redis import RedisClientKey, get_redis_client @@ -21,12 +22,13 @@ @pytest.fixture(params=[1, 20]) -def rate_limit_shards(request: Any) -> None: +def rate_limit_shards(request: Any) -> Iterator[None]: """ Use this fixture to run the test automatically against both 1 and 20 shards. """ - state.set_config("rate_limit_shard_factor", request.param) + with override_options("snuba", {"rate_limit_shard_factor": request.param}): + yield class TestRateLimit: @@ -167,10 +169,9 @@ def test_rate_limit_container(self) -> None: @pytest.mark.redis_db def test_bypass_rate_limit(self) -> None: rate_limit_params = RateLimitParameters("foo", "bar", None, None) - state.set_config("bypass_rate_limit", 1) - - with rate_limit(rate_limit_params) as stats: - assert stats is None + with override_options("snuba", {"bypass_rate_limit": 1}): + with rate_limit(rate_limit_params) as stats: + assert stats is None @pytest.mark.redis_db def test_rate_limit_exceptions(self) -> None: diff --git a/tests/state/test_sentry_options.py b/tests/state/test_sentry_options.py new file mode 100644 index 00000000000..a98b5b1fd29 --- /dev/null +++ b/tests/state/test_sentry_options.py @@ -0,0 +1,146 @@ +from unittest import mock + +from sentry_options.testing import override_options + +from snuba.state.sentry_options import ( + SNUBA_OPTIONS_NAMESPACE, + get_bool_option, + get_float_option, + get_int_option, + get_mapped_bool_option, + get_mapped_float_option, + get_mapped_int_option, + get_mapped_option, + get_mapped_str_option, + get_option, + get_str_option, +) + + +def test_get_option_returns_schema_default() -> None: + # `enable_any_attribute_filter` has a schema default of `true`. With + # sentry-options initialized (see tests/conftest.py) we read that schema + # default rather than the fallback passed here. + assert get_option("enable_any_attribute_filter", False) is True + + +def test_override_options_changes_value() -> None: + with override_options(SNUBA_OPTIONS_NAMESPACE, {"enable_any_attribute_filter": False}): + assert get_option("enable_any_attribute_filter", True) is False + # the override is scoped to the context manager; the default is restored. + assert get_option("enable_any_attribute_filter", False) is True + + +def test_unknown_option_falls_back_to_default() -> None: + # Keys absent from the schema raise UnknownOptionError internally, which + # get_option swallows in favor of the caller-supplied default. + assert get_option("option_that_does_not_exist", "fallback") == "fallback" + + +def test_typed_accessors_return_schema_defaults() -> None: + # Each typed accessor returns the schema default with the right Python type. + assert get_bool_option("aggregation_deprecation_enabled", False) is True + assert get_int_option("default_tier", 0) == 1 + assert get_int_option("export_trace_items_default_page_size", 0) == 10000 + assert get_float_option("rpc_logging_sample_rate", 1.0) == 0.0 + assert get_str_option("ExecutionStage.disable_max_query_size_check_for_clusters", "x") == "" + + +def test_typed_accessors_honor_overrides() -> None: + with override_options( + SNUBA_OPTIONS_NAMESPACE, + { + "aggregation_deprecation_enabled": False, + "default_tier": 8, + "rpc_logging_sample_rate": 0.25, + }, + ): + assert get_bool_option("aggregation_deprecation_enabled", True) is False + assert get_int_option("default_tier", 1) == 8 + assert get_float_option("rpc_logging_sample_rate", 0.0) == 0.25 + + +def test_typed_accessors_fall_back_on_unknown_option() -> None: + # Unknown keys fall back to the caller-supplied default at the right type. + assert get_bool_option("missing_bool", True) is True + assert get_int_option("missing_int", 7) == 7 + assert get_float_option("missing_float", 1.5) == 1.5 + assert get_str_option("missing_str", "fallback") == "fallback" + + +def test_unexpected_error_falls_back_to_default() -> None: + # The client should only ever raise OptionsError, but a non-OptionsError + # escaping from the client must not crash hot query paths: get_option (and + # the typed accessors built on it) honor the "any reason" fallback contract. + with mock.patch( + "snuba.state.sentry_options.sentry_options.options", + side_effect=RuntimeError("boom"), + ): + assert get_option("enable_any_attribute_filter", "fallback") == "fallback" + assert get_bool_option("enable_any_attribute_filter", True) is True + assert get_int_option("default_tier", 7) == 7 + assert get_float_option("rpc_logging_sample_rate", 1.5) == 1.5 + assert get_str_option("some_str", "fallback") == "fallback" + + +def test_mapped_option_returns_entry_for_name() -> None: + # A dict-typed option (additionalProperties) keyed by the dynamic name. + with override_options( + SNUBA_OPTIONS_NAMESPACE, + {"lw_deletes_split_by_partition": {"search_issues": 1, "errors": 0}}, + ): + assert get_mapped_int_option("lw_deletes_split_by_partition", "search_issues", 9) == 1 + assert get_mapped_int_option("lw_deletes_split_by_partition", "errors", 9) == 0 + + +def test_mapped_option_falls_back_for_absent_name() -> None: + with override_options( + SNUBA_OPTIONS_NAMESPACE, + {"lw_deletes_killswitch": {"search_issues": "[1]"}}, + ): + assert get_mapped_str_option("lw_deletes_killswitch", "search_issues", "") == "[1]" + # A name with no entry falls back to the caller default. + assert get_mapped_str_option("lw_deletes_killswitch", "transactions", "x") == "x" + + +def test_mapped_option_falls_back_when_option_unset() -> None: + # Each dict option defaults to {} (empty), so every name falls back to the + # caller-supplied default — preserving the pre-migration per-key default. + assert get_mapped_int_option("lw_deletes_split_by_partition", "search_issues", 7) == 7 + assert get_mapped_str_option("lw_deletes_killswitch", "search_issues", "d") == "d" + assert get_mapped_float_option("validate_schema_sample_rate", "events", 1.0) == 1.0 + + +def test_mapped_bool_option() -> None: + # `snql_disabled_dataset` is a bool-valued dict keyed by dataset name. + with override_options( + SNUBA_OPTIONS_NAMESPACE, + {"snql_disabled_dataset": {"events": True}}, + ): + assert get_mapped_bool_option("snql_disabled_dataset", "events", False) is True + # A dataset with no entry falls back to the caller default. + assert get_mapped_bool_option("snql_disabled_dataset", "transactions", False) is False + # When the option is unset every name falls back to the caller default. + assert get_mapped_bool_option("snql_disabled_dataset", "events", True) is True + + +def test_mapped_option_coerces_entry_to_requested_type() -> None: + # A number-typed dict may hold an integer JSON value; the typed accessor + # coerces it to float. + with override_options( + SNUBA_OPTIONS_NAMESPACE, + {"validate_schema_sample_rate": {"events": 1}}, + ): + result = get_mapped_float_option("validate_schema_sample_rate", "events", 0.0) + assert result == 1.0 + assert isinstance(result, float) + + +def test_mapped_option_base_accessor_and_unknown_option() -> None: + with override_options( + SNUBA_OPTIONS_NAMESPACE, + {"lw_deletes_killswitch": {"search_issues": "[1]"}}, + ): + assert get_mapped_option("lw_deletes_killswitch", "search_issues", "") == "[1]" + # An option absent from the schema falls back to the caller default. + assert get_mapped_option("option_that_does_not_exist", "x", "fallback") == "fallback" diff --git a/tests/subscriptions/test_builder_mode_state.py b/tests/subscriptions/test_builder_mode_state.py index 2837ad36ce5..d10144eac3c 100644 --- a/tests/subscriptions/test_builder_mode_state.py +++ b/tests/subscriptions/test_builder_mode_state.py @@ -2,8 +2,9 @@ from typing import Sequence, Tuple import pytest +from sentry_options.testing import override_options -from snuba import settings, state +from snuba import settings from snuba.subscriptions.data import Subscription from snuba.subscriptions.scheduler import TaskBuilderMode, TaskBuilderModeState from tests.subscriptions.subscriptions_utils import build_subscription @@ -99,11 +100,11 @@ def test_state_changes( ) -> None: prev_threshold = settings.MAX_RESOLUTION_FOR_JITTER settings.MAX_RESOLUTION_FOR_JITTER = 300 - state.set_config("subscription_primary_task_builder", general_mode) - mode_state = TaskBuilderModeState() - modes = [ - mode_state.get_current_mode(subscription, timestamp) - for subscription, timestamp in subscriptions - ] - assert modes == expected_modes + with override_options("snuba", {"subscription_primary_task_builder": general_mode}): + mode_state = TaskBuilderModeState() + modes = [ + mode_state.get_current_mode(subscription, timestamp) + for subscription, timestamp in subscriptions + ] + assert modes == expected_modes settings.MAX_RESOLUTION_FOR_JITTER = prev_threshold diff --git a/tests/subscriptions/test_executor_consumer.py b/tests/subscriptions/test_executor_consumer.py index 9f1b90a5f78..dc571f5f618 100644 --- a/tests/subscriptions/test_executor_consumer.py +++ b/tests/subscriptions/test_executor_consumer.py @@ -16,8 +16,8 @@ from arroyo.types import BrokerValue, Message, Partition, Topic from arroyo.utils.clock import MockedClock from confluent_kafka.admin import AdminClient +from sentry_options.testing import override_options -from snuba import state from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.datasets.factory import get_dataset @@ -341,9 +341,8 @@ def test_poll_skips_non_retryable_query_exception() -> None: @pytest.mark.redis_db @pytest.mark.clickhouse_db +@override_options("snuba", {"executor_queue_size_factor": 1}) def test_too_many_concurrent_queries() -> None: - state.set_config("executor_queue_size_factor", 1) - strategy = ExecuteQuery( dataset=get_dataset("events"), entity_names=["events"], diff --git a/tests/subscriptions/test_scheduler.py b/tests/subscriptions/test_scheduler.py index 9bbba7198c1..35b8203d59e 100644 --- a/tests/subscriptions/test_scheduler.py +++ b/tests/subscriptions/test_scheduler.py @@ -3,8 +3,8 @@ from typing import Callable, Collection, Optional, Tuple import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.redis import RedisClientKey, get_redis_client @@ -93,8 +93,8 @@ def run_test( assert result == expected @pytest.mark.redis_db + @override_options("snuba", {"subscription_primary_task_builder": "immediate"}) def test_simple(self) -> None: - state.set_config("subscription_primary_task_builder", "immediate") subscription = self.build_subscription(timedelta(minutes=1)) start = timedelta(minutes=-10) end = timedelta(minutes=0) @@ -169,8 +169,8 @@ def test_subscription_resolution_larger_than_interval(self) -> None: ) @pytest.mark.redis_db + @override_options("snuba", {"subscription_primary_task_builder": "immediate"}) def test_subscription_resolution_larger_than_tiny_interval(self) -> None: - state.set_config("subscription_primary_task_builder", "immediate") subscription = self.build_subscription(timedelta(minutes=1)) start = timedelta(seconds=-1) end = timedelta(seconds=1) @@ -228,8 +228,8 @@ def test_multiple_subscriptions(self) -> None: ) @pytest.mark.redis_db + @override_options("snuba", {"subscription_primary_task_builder": "immediate"}) def test_generic_metrics_gauges_does_not_error(self) -> None: - state.set_config("subscription_primary_task_builder", "immediate") subscription = Subscription( SubscriptionIdentifier(self.partition_id, uuid.uuid4()), SnQLSubscriptionData( diff --git a/tests/subscriptions/test_task_builder.py b/tests/subscriptions/test_task_builder.py index 3bf9ae6f21e..fe88a6c2532 100644 --- a/tests/subscriptions/test_task_builder.py +++ b/tests/subscriptions/test_task_builder.py @@ -2,8 +2,8 @@ from typing import Sequence, Tuple import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.datasets.entities.entity_key import EntityKey from snuba.subscriptions.data import ( ScheduledSubscriptionTask, @@ -228,14 +228,14 @@ def test_sequences( subscriptions and validate the proper jitter is applied. state. """ - state.set_config("subscription_primary_task_builder", primary_builder_config) - output = [] - for timestamp, subscription in sequence_in: - ret = builder.get_task( - SubscriptionWithMetadata(EntityKey.EVENTS, subscription, 1), timestamp - ) - if ret: - output.append((timestamp, ret)) + with override_options("snuba", {"subscription_primary_task_builder": primary_builder_config}): + output = [] + for timestamp, subscription in sequence_in: + ret = builder.get_task( + SubscriptionWithMetadata(EntityKey.EVENTS, subscription, 1), timestamp + ) + if ret: + output.append((timestamp, ret)) - assert output == task_sequence - assert builder.reset_metrics() == metrics + assert output == task_sequence + assert builder.reset_metrics() == metrics diff --git a/tests/test_api.py b/tests/test_api.py index f0a589360a4..8e1e368b53e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -11,6 +11,7 @@ import simplejson as json from confluent_kafka.admin import AdminClient from dateutil.parser import parse as parse_datetime +from sentry_options.testing import override_options from sentry_sdk import Client, Hub from snuba import settings, state @@ -28,7 +29,6 @@ from snuba.utils.streams.configuration_builder import get_default_kafka_configuration from snuba.utils.streams.topics import Topic as SnubaTopic from tests.base import BaseApiTest -from tests.conftest import SnubaSetConfig from tests.helpers import write_processed_messages @@ -62,7 +62,6 @@ def setup_teardown(self, events_db: None, redis_db: None) -> Generator[None, Non state.delete_config("project_concurrent_limit") state.delete_config("project_concurrent_limit_1") state.delete_config("project_per_second_limit") - state.delete_config("date_align_seconds") def write_events(self, events: Sequence[InsertEvent]) -> None: processor = self.storage.get_table_writer().get_stream_loader().get_processor() @@ -269,25 +268,25 @@ def test_time_alignment(self) -> None: # But if we set time alignment to an hour, the buckets will fall back to # the 1hr boundary. - state.set_config("date_align_seconds", 3600) - result = json.loads( - self.post( - json.dumps( - { - "project": 1, - "tenant_ids": {"referrer": "r", "organization_id": 1234}, - "granularity": 60, - "selected_columns": ["time"], - "groupby": "time", - "from_date": (self.base_time + skew).isoformat(), - "to_date": ( - self.base_time + skew + timedelta(minutes=self.minutes) - ).isoformat(), - "orderby": "time", - } - ), - ).data - ) + with override_options("snuba", {"date_align_seconds": 3600}): + result = json.loads( + self.post( + json.dumps( + { + "project": 1, + "tenant_ids": {"referrer": "r", "organization_id": 1234}, + "granularity": 60, + "selected_columns": ["time"], + "groupby": "time", + "from_date": (self.base_time + skew).isoformat(), + "to_date": ( + self.base_time + skew + timedelta(minutes=self.minutes) + ).isoformat(), + "orderby": "time", + } + ), + ).data + ) bucket_time = parse_datetime(result["data"][0]["time"]).replace(tzinfo=None) assert bucket_time == self.base_time @@ -1470,9 +1469,14 @@ def test_exception_captured_by_sentry(self) -> None: assert len(events) == 1 assert events[0]["exception"]["values"][0]["type"] == "ZeroDivisionError" + @override_options( + "snuba", + { + "read_through_cache.short_circuit": True, + "consistent_override": "test_override=0;another=0.5", + }, + ) def test_consistent(self) -> None: - state.set_config("consistent_override", "test_override=0;another=0.5") - state.set_config("read_through_cache.short_circuit", 1) query_data = { "project": 2, "tenant_ids": {"referrer": "test_query", "organization_id": 1234}, @@ -1490,7 +1494,7 @@ def test_consistent(self) -> None: query_data["tenant_ids"]["referrer"] = "test_override" # type: ignore query = json.dumps(query_data) response = json.loads(self.post(query, referrer="test_override").data) - assert response["stats"]["consistent"] == False + assert not response["stats"]["consistent"] def test_gracefully_handle_multiple_conditions_on_same_column(self) -> None: response = self.post( @@ -1961,5 +1965,6 @@ class TestAPIErrorsRO(TestApi): """ @pytest.fixture(autouse=True) - def use_readonly_table(self, snuba_set_config: SnubaSetConfig) -> None: - snuba_set_config("enable_events_readonly_table", 1) + def use_readonly_table(self) -> Generator[None, None, None]: + with override_options("snuba", {"enable_events_readonly_table": True}): + yield diff --git a/tests/test_configurable_component.py b/tests/test_configurable_component.py index 163100ee7c2..f69b4ff0cc4 100644 --- a/tests/test_configurable_component.py +++ b/tests/test_configurable_component.py @@ -1,8 +1,10 @@ from typing import cast import pytest +from sentry_options.testing import override_options from snuba.configs.configuration import ( + CONFIGURABLE_COMPONENT_OVERRIDES_KEY, ConfigurableComponent, Configuration, InvalidConfig, @@ -305,6 +307,47 @@ def test_delete_config_value_invalid_config( test_component.delete_config_value("invalid_config") +@pytest.mark.redis_db +class TestConfigurableComponentSentryOptions: + """The sentry-options override dict is the authoritative source for these + configs, taking precedence over the legacy Redis runtime config.""" + + _DEFAULT_KEY = "some_non_storage_resource.SomeConfigurableComponent.default_config_1" + + def test_override_takes_precedence_and_is_coerced( + self, test_component: SomeConfigurableComponent + ) -> None: + # Stored as a string, coerced to the config's declared int type. + with override_options( + "snuba", {CONFIGURABLE_COMPONENT_OVERRIDES_KEY: {self._DEFAULT_KEY: "200"}} + ): + assert test_component.get_config_value("default_config_1") == 200 + + def test_override_wins_over_redis(self, test_component: SomeConfigurableComponent) -> None: + # The legacy Redis value is only the fallback. + test_component.set_config_value("default_config_1", 5) + assert test_component.get_config_value("default_config_1") == 5 + with override_options( + "snuba", {CONFIGURABLE_COMPONENT_OVERRIDES_KEY: {self._DEFAULT_KEY: "7"}} + ): + assert test_component.get_config_value("default_config_1") == 7 + # Outside the override the Redis value is used again. + assert test_component.get_config_value("default_config_1") == 5 + + def test_override_with_params(self, test_component: SomeConfigurableComponent) -> None: + full_key = ( + "some_non_storage_resource.SomeConfigurableComponent." + "override_config_for_org_id.organization_id:10" + ) + with override_options("snuba", {CONFIGURABLE_COMPONENT_OVERRIDES_KEY: {full_key: "42"}}): + assert ( + test_component.get_config_value( + "override_config_for_org_id", params={"organization_id": 10} + ) + == 42 + ) + + class TestConfigurableComponentConfigRetrieval: """Test config retrieval methods.""" diff --git a/tests/test_discover_api.py b/tests/test_discover_api.py index 5c31dea7190..c6616159a61 100644 --- a/tests/test_discover_api.py +++ b/tests/test_discover_api.py @@ -1,15 +1,15 @@ from datetime import datetime, timedelta, timezone -from typing import Any, Callable, Tuple, Union +from typing import Any, Callable, Generator, Tuple, Union import pytest import simplejson as json +from sentry_options.testing import override_options from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity from snuba.datasets.storages.factory import get_writable_storage from snuba.datasets.storages.storage_key import StorageKey from tests.base import BaseApiTest -from tests.conftest import SnubaSetConfig from tests.fixtures import get_raw_event, get_raw_transaction from tests.helpers import write_unprocessed_events @@ -1771,7 +1771,7 @@ def test_symbolicated_in_app(self) -> None: data = json.loads(response.data) assert response.status_code == 200 assert len(data["data"]) == 1 - assert data["data"][0]["symbolicated_in_app"] == True + assert data["data"][0]["symbolicated_in_app"] def test_timestamp_ms_query(self) -> None: response = self.post( @@ -1849,5 +1849,6 @@ class TestDiscoverAPIErrorsRO(TestDiscoverApi): """ @pytest.fixture(autouse=True) - def use_readonly_table(self, snuba_set_config: SnubaSetConfig) -> None: - snuba_set_config("enable_events_readonly_table", 1) + def use_readonly_table(self) -> Generator[None, None, None]: + with override_options("snuba", {"enable_events_readonly_table": True}): + yield diff --git a/tests/test_metrics_api.py b/tests/test_metrics_api.py index 9d29d348b19..5cf58a3b184 100644 --- a/tests/test_metrics_api.py +++ b/tests/test_metrics_api.py @@ -59,7 +59,6 @@ def teardown_common() -> None: state.delete_config("project_concurrent_limit") state.delete_config("project_concurrent_limit_1") state.delete_config("project_per_second_limit") - state.delete_config("date_align_seconds") def utc_yesterday_12_15() -> datetime: diff --git a/tests/test_replacer.py b/tests/test_replacer.py index b6380170416..503b16183db 100644 --- a/tests/test_replacer.py +++ b/tests/test_replacer.py @@ -11,6 +11,7 @@ from arroyo.backends.kafka import KafkaPayload from arroyo.processing.strategies.healthcheck import Healthcheck from arroyo.types import BrokerValue, Message, Partition, Topic +from sentry_options.testing import override_options from snuba import replacer, settings from snuba.clickhouse.optimize.optimize import run_optimize @@ -365,7 +366,7 @@ def test_query_time_flags_groups(self) -> None: {ReplacementType.EXCLUDE_GROUPS}, ) - @mock.patch.object(settings, "REPLACER_MAX_GROUP_IDS_TO_EXCLUDE", 2) + @override_options("snuba", {"max_group_ids_exclude": 2}) def test_query_time_flags_bounded_size(self) -> None: redis_client.flushdb() project_id = 256 diff --git a/tests/test_search_issues_api.py b/tests/test_search_issues_api.py index 804afa5de24..02ea63fb835 100644 --- a/tests/test_search_issues_api.py +++ b/tests/test_search_issues_api.py @@ -5,11 +5,11 @@ import pytest import simplejson as json +from sentry_options.testing import override_options from snuba.core.initialize import initialize_snuba from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity -from snuba.state import set_config from tests.base import BaseApiTest from tests.datasets.configuration.utils import ConfigurationTest from tests.helpers import write_unprocessed_events @@ -105,8 +105,8 @@ def delete_query( ) @patch("snuba.web.bulk_delete_query.produce_delete_query") + @override_options("snuba", {"read_through_cache.short_circuit": True}) def test_simple_delete(self, mock_produce_delete: Mock) -> None: - set_config("read_through_cache.short_circuit", 1) now = datetime.now().replace(minute=0, second=0, microsecond=0) occurrence_id = str(uuid.uuid4()) group_id = 4 @@ -135,25 +135,25 @@ def test_simple_delete(self, mock_produce_delete: Mock) -> None: write_unprocessed_events(self.events_storage, [evt]) # delete fails when feature flag is off - set_config("storage_deletes_enabled", 0) - response = self.delete_query(group_id) - assert int(int(response.status_code) / 100) != 2 + with override_options("snuba", {"storage_deletes_enabled": False}): + response = self.delete_query(group_id) + assert int(int(response.status_code) / 100) != 2 # delete succeeds when feature flag is on - set_config("storage_deletes_enabled", 1) - response = self.delete_query(group_id) - data = json.loads(response.data) - assert response.status_code == 200, data + with override_options("snuba", {"storage_deletes_enabled": True}): + response = self.delete_query(group_id) + data = json.loads(response.data) + assert response.status_code == 200, data - # check we produce the delete query message - assert mock_produce_delete.call_count == 1 + # check we produce the delete query message + assert mock_produce_delete.call_count == 1 - # check args for delete query message - called_args = mock_produce_delete.call_args[0][0] - assert called_args["storage_name"] == "search_issues" - assert called_args["conditions"]["project_id"] == [3] - assert called_args["conditions"]["group_id"] == [4] - assert called_args["rows_to_delete"] == 1 + # check args for delete query message + called_args = mock_produce_delete.call_args[0][0] + assert called_args["storage_name"] == "search_issues" + assert called_args["conditions"]["project_id"] == [3] + assert called_args["conditions"]["group_id"] == [4] + assert called_args["rows_to_delete"] == 1 def test_bad_delete(self) -> None: res = self.app.delete( diff --git a/tests/test_snql_api.py b/tests/test_snql_api.py index 32dcb4e170e..dc1bbfa0733 100644 --- a/tests/test_snql_api.py +++ b/tests/test_snql_api.py @@ -3,13 +3,13 @@ import uuid from datetime import datetime, timedelta from hashlib import md5 -from typing import Any +from typing import Any, Generator from unittest.mock import MagicMock, patch import pytest import simplejson as json +from sentry_options.testing import override_options -from snuba import state from snuba.configs.configuration import Configuration, ResourceIdentifier from snuba.datasets.entities.entity_key import EntityKey from snuba.datasets.entities.factory import get_entity @@ -27,7 +27,6 @@ from snuba.querylog.query_metadata import QueryStatus from snuba.utils.metrics.backends.testing import get_recorded_metric_calls from tests.base import BaseApiTest -from tests.conftest import SnubaSetConfig from tests.fixtures import get_raw_event, get_raw_transaction from tests.helpers import override_entity_column_validator, write_unprocessed_events @@ -389,8 +388,8 @@ def test_record_queries_on_error( metadata = record_query_mock.call_args[0][0] assert metadata["query_list"][0]["stats"]["error_code"] == 1123 + @override_options("snuba", {"snuba_api_cogs_probability": 1.0}) def test_record_queries_cogs(self) -> None: - state.set_config("snuba_api_cogs_probability", 1.0) with patch("snuba.querylog._record_cogs") as record_cogs_mock: result = json.loads( self.post( @@ -1579,5 +1578,6 @@ class TestSnQLApiErrorsRO(TestSnQLApi): """ @pytest.fixture(autouse=True) - def use_readonly_table(self, snuba_set_config: SnubaSetConfig) -> None: - snuba_set_config("enable_events_readonly_table", 1) + def use_readonly_table(self) -> Generator[None, None, None]: + with override_options("snuba", {"enable_events_readonly_table": True}): + yield diff --git a/tests/test_transactions_api.py b/tests/test_transactions_api.py index cc7d9f635db..c4c66ba5724 100644 --- a/tests/test_transactions_api.py +++ b/tests/test_transactions_api.py @@ -61,7 +61,6 @@ def setup_teardown( state.delete_config("project_concurrent_limit") state.delete_config("project_concurrent_limit_1") state.delete_config("project_per_second_limit") - state.delete_config("date_align_seconds") def generate_fizzbuzz_events(self) -> None: """ diff --git a/tests/web/rpc/test_common.py b/tests/web/rpc/test_common.py index 7e32b8c515f..fba85b6fdfc 100644 --- a/tests/web/rpc/test_common.py +++ b/tests/web/rpc/test_common.py @@ -4,6 +4,7 @@ import pytest from google.protobuf import json_format, struct_pb2 from google.protobuf.timestamp_pb2 import Timestamp +from sentry_options.testing import override_options from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( Column, TraceItemTableRequest, @@ -96,9 +97,9 @@ def test_use_sampling_factor(self, snuba_set_config: SnubaSetConfig) -> None: ) ) ) - snuba_set_config("use_sampling_factor_timestamp_seconds", 10) - assert use_sampling_factor(RequestMeta(start_timestamp=Timestamp(seconds=10))) - assert not use_sampling_factor(RequestMeta(start_timestamp=Timestamp(seconds=9))) + with override_options("snuba", {"use_sampling_factor_timestamp_seconds": 10}): + assert use_sampling_factor(RequestMeta(start_timestamp=Timestamp(seconds=10))) + assert not use_sampling_factor(RequestMeta(start_timestamp=Timestamp(seconds=9))) @pytest.mark.redis_db def test_use_array_map_columns(self, snuba_set_config: SnubaSetConfig) -> None: @@ -115,11 +116,11 @@ def test_use_array_map_columns(self, snuba_set_config: SnubaSetConfig) -> None: ) ) # A config value of 0 disables the typed-column read path entirely. - snuba_set_config("use_array_map_columns_timestamp_seconds", 0) - assert not use_array_map_columns(RequestMeta(start_timestamp=Timestamp(seconds=2**31))) - snuba_set_config("use_array_map_columns_timestamp_seconds", 10) - assert use_array_map_columns(RequestMeta(start_timestamp=Timestamp(seconds=10))) - assert not use_array_map_columns(RequestMeta(start_timestamp=Timestamp(seconds=9))) + with override_options("snuba", {"use_array_map_columns_timestamp_seconds": 0}): + assert not use_array_map_columns(RequestMeta(start_timestamp=Timestamp(seconds=2**31))) + with override_options("snuba", {"use_array_map_columns_timestamp_seconds": 10}): + assert use_array_map_columns(RequestMeta(start_timestamp=Timestamp(seconds=10))) + assert not use_array_map_columns(RequestMeta(start_timestamp=Timestamp(seconds=9))) class TestTraceItemFiltersArrayLike: @@ -1226,3 +1227,31 @@ def test_like_wildcard_matches_present_not_absent(self) -> None: def test_not_like_wildcard_matches_only_absent(self) -> None: # Present rows all `like '%'`, so only the absent key survives NOT LIKE. assert self._execute(ComparisonFilter.OP_NOT_LIKE, value="%") == ["green"] + + +class TestAnyAttributeFilterOption: + """The `enable_any_attribute_filter` sentry-option gates whether + any_attribute_filter is translated into a predicate or treated as + always-true. It replaces the former `enable_any_attribute_filter` + runtime config.""" + + @staticmethod + def _filter() -> TraceItemFilter: + return TraceItemFilter( + any_attribute_filter=AnyAttributeFilter( + op=AnyAttributeFilter.OP_EQUALS, + value=AttributeValue(val_str="foo"), + ) + ) + + def test_enabled_by_default_translates_filter(self) -> None: + # Schema default is true: the filter is translated, not short-circuited. + result = trace_item_filters_to_expression(self._filter(), attribute_key_to_expression) + assert isinstance(result, FunctionCall) + assert result.function_name == "arrayExists" + + def test_disabled_returns_always_true(self) -> None: + with override_options("snuba", {"enable_any_attribute_filter": False}): + result = trace_item_filters_to_expression(self._filter(), attribute_key_to_expression) + assert isinstance(result, Literal) + assert result.value is True diff --git a/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py b/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py index 33a3357db02..7ef94d6f5f2 100644 --- a/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py +++ b/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py @@ -5,6 +5,7 @@ import pytest from google.protobuf.timestamp_pb2 import Timestamp +from sentry_options.testing import override_options from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig from sentry_protos.snuba.v1.endpoint_get_traces_pb2 import GetTracesRequest from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest @@ -16,7 +17,6 @@ ) from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter -from snuba import state from snuba.downsampled_storage_tiers import Tier from snuba.utils.metrics.timer import Timer from snuba.web import QueryResult @@ -95,15 +95,12 @@ def test_outcomes_based_routing_queries_daily_table() -> None: @pytest.mark.eap @pytest.mark.redis_db +@override_options("snuba", {"enable_long_term_retention_downsampling": True}) def test_item_type_full_retention() -> None: """ Certain item types will not use the long term retention downsampling, find them in ITEM_TYPE_FULL_RETENTION routing_strategies/common.py """ - state.set_config( - "enable_long_term_retention_downsampling", - 1, - ) strategy = OutcomesBasedRoutingStrategy() # request that queries last 50 days of data @@ -130,15 +127,12 @@ def test_item_type_full_retention() -> None: @pytest.mark.eap @pytest.mark.redis_db +@override_options("snuba", {"enable_long_term_retention_downsampling": True}) def test_item_type_full_retention_preprod() -> None: """ PREPROD item type should not use long term retention downsampling, it should always fetch tier1 for its 90 day retention period. """ - state.set_config( - "enable_long_term_retention_downsampling", - 1, - ) strategy = OutcomesBasedRoutingStrategy() # request that queries last 50 days of data @@ -165,11 +159,8 @@ def test_item_type_full_retention_preprod() -> None: @pytest.mark.eap @pytest.mark.redis_db +@override_options("snuba", {"enable_long_term_retention_downsampling": True}) def test_outcomes_based_routing_sampled_data_past_thirty_days() -> None: - state.set_config( - "enable_long_term_retention_downsampling", - 1, - ) strategy = OutcomesBasedRoutingStrategy() end_time = datetime.now(UTC).replace(hour=0, minute=0, second=0, microsecond=0) @@ -295,52 +286,54 @@ def fake_run_query(dataset: Any, request: Any, timer: Any) -> Any: @pytest.mark.eap @pytest.mark.redis_db def test_outcomes_based_routing_downsample(store_outcomes_fixture: Any) -> None: - state.set_config("OutcomesBasedRoutingStrategy.max_items_before_downsampling", 5_000_000) strategy = OutcomesBasedRoutingStrategy() request = TraceItemTableRequest(meta=_get_request_meta()) request.meta.downsampled_storage_config.mode = DownsampledStorageConfig.MODE_NORMAL - routing_decision = strategy.get_routing_decision( - RoutingContext( - in_msg=request, - timer=Timer("test"), - query_id=uuid.uuid4().hex, + option = "storage_routing_max_items_before_downsampling" + + with override_options("snuba", {option: {"OutcomesBasedRoutingStrategy": 5_000_000}}): + routing_decision = strategy.get_routing_decision( + RoutingContext( + in_msg=request, + timer=Timer("test"), + query_id=uuid.uuid4().hex, + ) ) - ) - assert routing_decision.tier == Tier.TIER_8 - assert routing_decision.clickhouse_settings == {"max_threads": 10} - assert routing_decision.can_run - state.set_config("OutcomesBasedRoutingStrategy.max_items_before_downsampling", 500_000) - routing_decision = strategy.get_routing_decision( - RoutingContext( - in_msg=request, - timer=Timer("test"), - query_id=uuid.uuid4().hex, + assert routing_decision.tier == Tier.TIER_8 + assert routing_decision.clickhouse_settings == {"max_threads": 10} + assert routing_decision.can_run + + with override_options("snuba", {option: {"OutcomesBasedRoutingStrategy": 500_000}}): + routing_decision = strategy.get_routing_decision( + RoutingContext( + in_msg=request, + timer=Timer("test"), + query_id=uuid.uuid4().hex, + ) ) - ) - assert routing_decision.tier == Tier.TIER_64 - assert routing_decision.clickhouse_settings == {"max_threads": 10} - assert routing_decision.can_run + assert routing_decision.tier == Tier.TIER_64 + assert routing_decision.clickhouse_settings == {"max_threads": 10} + assert routing_decision.can_run - state.set_config("OutcomesBasedRoutingStrategy.max_items_before_downsampling", 50_000) - routing_decision = strategy.get_routing_decision( - RoutingContext( - in_msg=request, - timer=Timer("test"), - query_id=uuid.uuid4().hex, + with override_options("snuba", {option: {"OutcomesBasedRoutingStrategy": 50_000}}): + routing_decision = strategy.get_routing_decision( + RoutingContext( + in_msg=request, + timer=Timer("test"), + query_id=uuid.uuid4().hex, + ) ) - ) - assert routing_decision.tier == Tier.TIER_512 - assert routing_decision.clickhouse_settings == {"max_threads": 10} - assert routing_decision.can_run + assert routing_decision.tier == Tier.TIER_512 + assert routing_decision.clickhouse_settings == {"max_threads": 10} + assert routing_decision.can_run @pytest.mark.eap @pytest.mark.redis_db def test_outcomes_based_routing_per_org_override(store_outcomes_fixture: Any) -> None: # global says no downsampling; per-org override forces TIER_64 - state.set_config("OutcomesBasedRoutingStrategy.max_items_before_downsampling", 1_000_000_000) strategy = OutcomesBasedRoutingStrategy() strategy.set_config_value( "max_items_before_downsampling", @@ -351,29 +344,39 @@ def test_outcomes_based_routing_per_org_override(store_outcomes_fixture: Any) -> request = TraceItemTableRequest(meta=_get_request_meta()) request.meta.downsampled_storage_config.mode = DownsampledStorageConfig.MODE_NORMAL - routing_decision = strategy.get_routing_decision( - RoutingContext( - in_msg=request, - timer=Timer("test"), - query_id=uuid.uuid4().hex, + with override_options( + "snuba", + { + "storage_routing_max_items_before_downsampling": { + "OutcomesBasedRoutingStrategy": 1_000_000_000 + } + }, + ): + routing_decision = strategy.get_routing_decision( + RoutingContext( + in_msg=request, + timer=Timer("test"), + query_id=uuid.uuid4().hex, + ) + ) + assert routing_decision.tier == Tier.TIER_64 + assert routing_decision.can_run + + # different org with no override falls back to the global config + other_org_request = TraceItemTableRequest(meta=_get_request_meta()) + other_org_request.meta.organization_id = _ORG_ID + 1 + other_org_request.meta.downsampled_storage_config.mode = ( + DownsampledStorageConfig.MODE_NORMAL ) - ) - assert routing_decision.tier == Tier.TIER_64 - assert routing_decision.can_run - - # different org with no override falls back to the global config - other_org_request = TraceItemTableRequest(meta=_get_request_meta()) - other_org_request.meta.organization_id = _ORG_ID + 1 - other_org_request.meta.downsampled_storage_config.mode = DownsampledStorageConfig.MODE_NORMAL - other_routing_decision = strategy.get_routing_decision( - RoutingContext( - in_msg=other_org_request, - timer=Timer("test"), - query_id=uuid.uuid4().hex, + other_routing_decision = strategy.get_routing_decision( + RoutingContext( + in_msg=other_org_request, + timer=Timer("test"), + query_id=uuid.uuid4().hex, + ) ) - ) - assert other_routing_decision.tier == Tier.TIER_1 + assert other_routing_decision.tier == Tier.TIER_1 @pytest.mark.eap @@ -405,14 +408,17 @@ def test_outcomes_based_routing_defaults_to_tier1_for_unspecified_item_type( request = TraceItemTableRequest(meta=_get_request_meta()) request.meta.trace_item_type = TraceItemType.TRACE_ITEM_TYPE_UNSPECIFIED - state.set_config("OutcomesBasedRoutingStrategy.max_items_before_downsampling", 50_000) - routing_decision = strategy.get_routing_decision( - RoutingContext( - in_msg=request, - timer=Timer("test"), - query_id=uuid.uuid4().hex, + with override_options( + "snuba", + {"storage_routing_max_items_before_downsampling": {"OutcomesBasedRoutingStrategy": 50_000}}, + ): + routing_decision = strategy.get_routing_decision( + RoutingContext( + in_msg=request, + timer=Timer("test"), + query_id=uuid.uuid4().hex, + ) ) - ) assert routing_decision.tier == Tier.TIER_1 assert routing_decision.clickhouse_settings == {"max_threads": 10} assert routing_decision.can_run diff --git a/tests/web/rpc/v1/routing_strategies/test_strategy_selector.py b/tests/web/rpc/v1/routing_strategies/test_strategy_selector.py index 28090008585..2bc05ca48cc 100644 --- a/tests/web/rpc/v1/routing_strategies/test_strategy_selector.py +++ b/tests/web/rpc/v1/routing_strategies/test_strategy_selector.py @@ -3,10 +3,10 @@ from unittest.mock import patch import pytest +from sentry_options.testing import override_options from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta -from snuba import state from snuba.configs.configuration import Configuration from snuba.utils.metrics.timer import Timer from snuba.web.rpc.storage_routing.routing_strategies.outcomes_based import ( @@ -54,112 +54,115 @@ def test_strategy_selector_selects_default_if_no_config() -> None: @pytest.mark.redis_db def test_strategy_selector_selects_default_if_strategy_does_not_exist() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"NonExistentStrategy": 1}}', - ) - storage_routing_config = RoutingStrategySelector().get_storage_routing_config( - TimeSeriesRequest(meta=RequestMeta(organization_id=1)) - ) - assert storage_routing_config == _DEFAULT_STORAGE_ROUTING_CONFIG + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"NonExistentStrategy": 1}}', + }, + ): + storage_routing_config = RoutingStrategySelector().get_storage_routing_config( + TimeSeriesRequest(meta=RequestMeta(organization_id=1)) + ) + assert storage_routing_config == _DEFAULT_STORAGE_ROUTING_CONFIG @pytest.mark.redis_db def test_strategy_selector_selects_default_if_percentages_do_not_add_up() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.1, "ToyRoutingStrategy1": 0.2, "ToyRoutingStrategy2": 0.10}}', - ) - storage_routing_config = RoutingStrategySelector().get_storage_routing_config( - TimeSeriesRequest(meta=RequestMeta(organization_id=1)) - ) - assert storage_routing_config == _DEFAULT_STORAGE_ROUTING_CONFIG + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.1, "ToyRoutingStrategy1": 0.2, "ToyRoutingStrategy2": 0.10}}', + }, + ): + storage_routing_config = RoutingStrategySelector().get_storage_routing_config( + TimeSeriesRequest(meta=RequestMeta(organization_id=1)) + ) + assert storage_routing_config == _DEFAULT_STORAGE_ROUTING_CONFIG @pytest.mark.redis_db def test_valid_config_is_parsed_correctly() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.1, "ToyRoutingStrategy1": 0.2, "ToyRoutingStrategy2": 0.70}}', - ) - storage_routing_config = RoutingStrategySelector().get_storage_routing_config( - TimeSeriesRequest(meta=RequestMeta(organization_id=1)) - ) + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.1, "ToyRoutingStrategy1": 0.2, "ToyRoutingStrategy2": 0.70}}', + }, + ): + storage_routing_config = RoutingStrategySelector().get_storage_routing_config( + TimeSeriesRequest(meta=RequestMeta(organization_id=1)) + ) - assert storage_routing_config.version == 1 - assert storage_routing_config.get_routing_strategy_and_percentage_routed() == [ - ("OutcomesBasedRoutingStrategy", 0.1), - ("ToyRoutingStrategy1", 0.2), - ("ToyRoutingStrategy2", 0.7), - ] + assert storage_routing_config.version == 1 + assert storage_routing_config.get_routing_strategy_and_percentage_routed() == [ + ("OutcomesBasedRoutingStrategy", 0.1), + ("ToyRoutingStrategy1", 0.2), + ("ToyRoutingStrategy2", 0.7), + ] @pytest.mark.redis_db def test_selects_same_strategy_for_same_org_and_project_ids() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.25, "ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.25, "ToyRoutingStrategy3": 0.25}}', - ) - - routing_context = RoutingContext( - in_msg=TimeSeriesRequest( - meta=RequestMeta( - organization_id=11, - project_ids=[14, 15, 16], - ), - ), - timer=Timer(name="doesntmatter"), - query_id=uuid.uuid4().hex, - ) - - for _ in range(50): - assert isinstance( - RoutingStrategySelector().select_routing_strategy(routing_context), - OutcomesBasedRoutingStrategy, - ) - - -@pytest.mark.redis_db -def test_selects_strategy_based_on_non_uniform_distribution() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.10, "ToyRoutingStrategy1": 0.90}}', - ) - - strategy_counts = {OutcomesBasedRoutingStrategy: 0, ToyRoutingStrategy1: 0} - - selector = RoutingStrategySelector() - - for _ in range(1000): + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.25, "ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.25, "ToyRoutingStrategy3": 0.25}}', + }, + ): routing_context = RoutingContext( in_msg=TimeSeriesRequest( meta=RequestMeta( - organization_id=random.randint(1, 1000), - project_ids=[ - random.randint(1, 1000) - for _ in range(random.randint(1, random.randint(1, 10))) - ], + organization_id=11, + project_ids=[14, 15, 16], ), ), timer=Timer(name="doesntmatter"), query_id=uuid.uuid4().hex, ) - strategy = selector.select_routing_strategy(routing_context) - strategy_counts[type(strategy)] += 1 - # about 100 should be routed, 400 is a generous upper bound - assert strategy_counts[OutcomesBasedRoutingStrategy] < 400 - # about 900 should be routed, 600 is a generous lower bound - assert strategy_counts[ToyRoutingStrategy1] > 600 + for _ in range(50): + assert isinstance( + RoutingStrategySelector().select_routing_strategy(routing_context), + OutcomesBasedRoutingStrategy, + ) @pytest.mark.redis_db -def test_config_ordering_does_not_affect_routing_consistency() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.55, "OutcomesBasedRoutingStrategy": 0.2}}', - ) +def test_selects_strategy_based_on_non_uniform_distribution() -> None: + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.10, "ToyRoutingStrategy1": 0.90}}', + }, + ): + strategy_counts = {OutcomesBasedRoutingStrategy: 0, ToyRoutingStrategy1: 0} + + selector = RoutingStrategySelector() + + for _ in range(1000): + routing_context = RoutingContext( + in_msg=TimeSeriesRequest( + meta=RequestMeta( + organization_id=random.randint(1, 1000), + project_ids=[ + random.randint(1, 1000) + for _ in range(random.randint(1, random.randint(1, 10))) + ], + ), + ), + timer=Timer(name="doesntmatter"), + query_id=uuid.uuid4().hex, + ) + strategy = selector.select_routing_strategy(routing_context) + strategy_counts[type(strategy)] += 1 + + # about 100 should be routed, 400 is a generous upper bound + assert strategy_counts[OutcomesBasedRoutingStrategy] < 400 + # about 900 should be routed, 600 is a generous lower bound + assert strategy_counts[ToyRoutingStrategy1] > 600 + +@pytest.mark.redis_db +def test_config_ordering_does_not_affect_routing_consistency() -> None: routing_context = RoutingContext( in_msg=TimeSeriesRequest( meta=RequestMeta( @@ -171,81 +174,82 @@ def test_config_ordering_does_not_affect_routing_consistency() -> None: query_id=uuid.uuid4().hex, ) - assert isinstance( - RoutingStrategySelector().select_routing_strategy(routing_context), - ToyRoutingStrategy1, - ) - - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"ToyRoutingStrategy1": 0.25, "OutcomesBasedRoutingStrategy": 0.2, "ToyRoutingStrategy2": 0.55}}', - ) + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.55, "OutcomesBasedRoutingStrategy": 0.2}}', + }, + ): + assert isinstance( + RoutingStrategySelector().select_routing_strategy(routing_context), + ToyRoutingStrategy1, + ) - assert isinstance( - RoutingStrategySelector().select_routing_strategy(routing_context), - ToyRoutingStrategy1, - ) + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"ToyRoutingStrategy1": 0.25, "OutcomesBasedRoutingStrategy": 0.2, "ToyRoutingStrategy2": 0.55}}', + }, + ): + assert isinstance( + RoutingStrategySelector().select_routing_strategy(routing_context), + ToyRoutingStrategy1, + ) @pytest.mark.redis_db def test_selects_override_if_it_exists() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.25, "ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.25, "ToyRoutingStrategy3": 0.25}}', - ) - - state.set_config( - _STORAGE_ROUTING_CONFIG_OVERRIDE_KEY, - '{"10": {"version": 1, "config": {"ToyRoutingStrategy1": 0.95, "ToyRoutingStrategy2": 0.05}}}', - ) - - routing_context = RoutingContext( - in_msg=TimeSeriesRequest( - meta=RequestMeta( - organization_id=10, - project_ids=[11, 12], + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.25, "ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.25, "ToyRoutingStrategy3": 0.25}}', + _STORAGE_ROUTING_CONFIG_OVERRIDE_KEY: '{"10": {"version": 1, "config": {"ToyRoutingStrategy1": 0.95, "ToyRoutingStrategy2": 0.05}}}', + }, + ): + routing_context = RoutingContext( + in_msg=TimeSeriesRequest( + meta=RequestMeta( + organization_id=10, + project_ids=[11, 12], + ), ), - ), - timer=Timer(name="doesntmatter"), - query_id=uuid.uuid4().hex, - ) + timer=Timer(name="doesntmatter"), + query_id=uuid.uuid4().hex, + ) - assert RoutingStrategySelector().get_storage_routing_config( - routing_context.in_msg - ).get_routing_strategy_and_percentage_routed() == [ - ("ToyRoutingStrategy1", 0.95), - ("ToyRoutingStrategy2", 0.05), - ] + assert RoutingStrategySelector().get_storage_routing_config( + routing_context.in_msg + ).get_routing_strategy_and_percentage_routed() == [ + ("ToyRoutingStrategy1", 0.95), + ("ToyRoutingStrategy2", 0.05), + ] @pytest.mark.redis_db def test_does_not_override_if_organization_id_is_different() -> None: - state.set_config( - _DEFAULT_STORAGE_ROUTING_CONFIG_KEY, - '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.25, "ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.25, "ToyRoutingStrategy3": 0.25}}', - ) - - state.set_config( - _STORAGE_ROUTING_CONFIG_OVERRIDE_KEY, - '{"10": {"version": 1, "config": {"ToyRoutingStrategy1": 0.95, "ToyRoutingStrategy2": 0.05}}}', - ) - - routing_context = RoutingContext( - in_msg=TimeSeriesRequest( - meta=RequestMeta( - organization_id=11, - project_ids=[11, 12], + with override_options( + "snuba", + { + _DEFAULT_STORAGE_ROUTING_CONFIG_KEY: '{"version": 1, "config": {"OutcomesBasedRoutingStrategy": 0.25, "ToyRoutingStrategy1": 0.25, "ToyRoutingStrategy2": 0.25, "ToyRoutingStrategy3": 0.25}}', + _STORAGE_ROUTING_CONFIG_OVERRIDE_KEY: '{"10": {"version": 1, "config": {"ToyRoutingStrategy1": 0.95, "ToyRoutingStrategy2": 0.05}}}', + }, + ): + routing_context = RoutingContext( + in_msg=TimeSeriesRequest( + meta=RequestMeta( + organization_id=11, + project_ids=[11, 12], + ), ), - ), - timer=Timer(name="doesntmatter"), - query_id=uuid.uuid4().hex, - ) + timer=Timer(name="doesntmatter"), + query_id=uuid.uuid4().hex, + ) - assert RoutingStrategySelector().get_storage_routing_config( - routing_context.in_msg - ).get_routing_strategy_and_percentage_routed() == [ - ("OutcomesBasedRoutingStrategy", 0.25), - ("ToyRoutingStrategy1", 0.25), - ("ToyRoutingStrategy2", 0.25), - ("ToyRoutingStrategy3", 0.25), - ] + assert RoutingStrategySelector().get_storage_routing_config( + routing_context.in_msg + ).get_routing_strategy_and_percentage_routed() == [ + ("OutcomesBasedRoutingStrategy", 0.25), + ("ToyRoutingStrategy1", 0.25), + ("ToyRoutingStrategy2", 0.25), + ("ToyRoutingStrategy3", 0.25), + ] diff --git a/tests/web/rpc/v1/test_endpoint_get_trace.py b/tests/web/rpc/v1/test_endpoint_get_trace.py index fc625d7edf1..69e66e19947 100644 --- a/tests/web/rpc/v1/test_endpoint_get_trace.py +++ b/tests/web/rpc/v1/test_endpoint_get_trace.py @@ -7,6 +7,7 @@ import pytest from google.protobuf.json_format import MessageToDict from google.protobuf.timestamp_pb2 import Timestamp +from sentry_options.testing import override_options from sentry_protos.snuba.v1.endpoint_get_trace_pb2 import ( GetTraceRequest, GetTraceResponse, @@ -28,10 +29,10 @@ ) from sentry_protos.snuba.v1.trace_item_pb2 import AnyValue, TraceItem -from snuba import state from snuba.datasets.storages.factory import get_storage from snuba.datasets.storages.storage_key import StorageKey from snuba.settings import ENABLE_TRACE_PAGINATION_DEFAULT +from snuba.state.sentry_options import get_bool_option from snuba.web.rpc.common.common import ATTRIBUTES_ARRAY_ALLOWLIST from snuba.web.rpc.v1.endpoint_get_trace import ( APPLY_FINAL_ROLLOUT_PERCENTAGE_CONFIG_KEY, @@ -232,7 +233,7 @@ def test_with_data_all_attributes(self, setup_teardown: Any) -> None: ], page_token=( PageToken(end_pagination=True) - if state.get_int_config("enable_trace_pagination", ENABLE_TRACE_PAGINATION_DEFAULT) + if get_bool_option("enable_trace_pagination", bool(ENABLE_TRACE_PAGINATION_DEFAULT)) else None ), ) @@ -327,7 +328,7 @@ def test_with_specific_attributes(self, setup_teardown: Any) -> None: ], page_token=( PageToken(end_pagination=True) - if state.get_int_config("enable_trace_pagination", ENABLE_TRACE_PAGINATION_DEFAULT) + if get_bool_option("enable_trace_pagination", bool(ENABLE_TRACE_PAGINATION_DEFAULT)) else None ), ) @@ -364,23 +365,13 @@ def test_build_query_with_final(store_outcomes_data: Any) -> None: items=[item], ) - state.set_config( - APPLY_FINAL_ROLLOUT_PERCENTAGE_CONFIG_KEY, - 1.0, - ) - - query = _build_query(message, item) - - assert query.get_final() - - state.set_config( - APPLY_FINAL_ROLLOUT_PERCENTAGE_CONFIG_KEY, - 0.0, - ) - - query = _build_query(message, item) + with override_options("snuba", {APPLY_FINAL_ROLLOUT_PERCENTAGE_CONFIG_KEY: 1.0}): + query = _build_query(message, item) + assert query.get_final() - assert not query.get_final() + with override_options("snuba", {APPLY_FINAL_ROLLOUT_PERCENTAGE_CONFIG_KEY: 0.0}): + query = _build_query(message, item) + assert not query.get_final() def test_with_logs(self, setup_teardown: Any) -> None: ts = Timestamp(seconds=int(_BASE_TIME.timestamp())) @@ -449,7 +440,7 @@ def test_with_logs(self, setup_teardown: Any) -> None: ], page_token=( PageToken(end_pagination=True) - if state.get_int_config("enable_trace_pagination", ENABLE_TRACE_PAGINATION_DEFAULT) + if get_bool_option("enable_trace_pagination", bool(ENABLE_TRACE_PAGINATION_DEFAULT)) else None ), ) @@ -578,7 +569,6 @@ def test_process_results_keeps_empty_string_attribute() -> None: class TestGetTracePagination(BaseApiTest): def test_pagination_with_user_limit(self, setup_teardown: Any) -> None: """Test that pagination respects user-provided limit""" - state.set_config("enable_trace_pagination", 1) ts = Timestamp(seconds=int(_BASE_TIME.timestamp())) three_hours_later = int((_BASE_TIME + timedelta(hours=3)).timestamp()) mylimit = 10 @@ -640,7 +630,6 @@ def test_pagination_with_no_user_limit(self, setup_teardown: Any) -> None: with patch( "snuba.web.rpc.v1.endpoint_get_trace.ENDPOINT_GET_TRACE_PAGINATION_MAX_ITEMS", configmax ): - state.set_config("enable_trace_pagination", 1) """ import snuba.web.rpc.v1.endpoint_get_trace as endpoint_get_trace diff --git a/tests/web/rpc/v1/test_endpoint_get_traces.py b/tests/web/rpc/v1/test_endpoint_get_traces.py index 30b94387792..459e84d4062 100644 --- a/tests/web/rpc/v1/test_endpoint_get_traces.py +++ b/tests/web/rpc/v1/test_endpoint_get_traces.py @@ -1,11 +1,12 @@ import uuid from collections import defaultdict from datetime import datetime, timedelta, timezone -from typing import Any +from typing import Any, Generator import pytest from google.protobuf.json_format import MessageToDict from google.protobuf.timestamp_pb2 import Timestamp +from sentry_options.testing import override_options from sentry_protos.snuba.v1.endpoint_get_traces_pb2 import ( GetTracesRequest, GetTracesResponse, @@ -43,7 +44,6 @@ from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException from snuba.web.rpc.v1.endpoint_get_traces import EndpointGetTraces from tests.base import BaseApiTest -from tests.conftest import SnubaSetConfig from tests.helpers import write_raw_unprocessed_events from tests.web.rpc.v1.test_utils import ( comparison_filter, @@ -989,8 +989,7 @@ class TestEndpointGetTracesCrossItem(TestEndpointGetTraces): """Run all tests with use_cross_item_path_for_single_item_queries enabled.""" @pytest.fixture(autouse=True) - def use_cross_item_path( - self, clickhouse_db: Any, redis_db: Any, snuba_set_config: SnubaSetConfig - ) -> None: + def use_cross_item_path(self, clickhouse_db: Any, redis_db: Any) -> Generator[None, None, None]: """Enable the feature flag for cross-item path for all tests in this class.""" - snuba_set_config("use_cross_item_path_for_single_item_queries", 1) + with override_options("snuba", {"use_cross_item_path_for_single_item_queries": True}): + yield diff --git a/tests/web/rpc/v1/test_endpoint_time_series/test_endpoint_time_series_cross_item_sampling.py b/tests/web/rpc/v1/test_endpoint_time_series/test_endpoint_time_series_cross_item_sampling.py index 95ad4a199ba..cc75d33ef76 100644 --- a/tests/web/rpc/v1/test_endpoint_time_series/test_endpoint_time_series_cross_item_sampling.py +++ b/tests/web/rpc/v1/test_endpoint_time_series/test_endpoint_time_series_cross_item_sampling.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from sentry_options.testing import override_options from sentry_protos.snuba.v1.endpoint_time_series_pb2 import ( Expression, TimeSeriesRequest, @@ -17,7 +18,6 @@ ) from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter -from snuba import state from snuba.datasets.storages.storage_key import StorageKey from snuba.downsampled_storage_tiers import Tier from snuba.web.rpc import RPCEndpoint @@ -83,9 +83,6 @@ def test_cross_item_query_sampling_enabled(self) -> None: - The inner query uses downsampled storage (TIER_8) - The outer query uses full storage (EAP_ITEMS) """ - # Enable the feature flag - state.set_config("cross_item_queries_no_sample_outer", 1) - trace_ids, all_items, start_time, end_time = create_cross_item_test_data() write_cross_item_data_to_storage(all_items) @@ -102,7 +99,11 @@ def test_cross_item_query_sampling_enabled(self) -> None: storage_keys, storage_tracker = track_storage_selections() - with storage_tracker: + # Enable the feature flag for the duration of the query execution. + with ( + override_options("snuba", {"cross_item_queries_no_sample_outer": True}), + storage_tracker, + ): with patch.object(RPCEndpoint, "_RPCEndpoint__before_execute"): message = create_time_series_request( start_time=start_time, @@ -139,9 +140,6 @@ def test_cross_item_query_sampling_disabled(self) -> None: Test that when cross_item_queries_no_sample_outer is disabled (default): - Both queries use the same storage tier """ - # Explicitly disable the feature flag - state.set_config("cross_item_queries_no_sample_outer", 0) - trace_ids, all_items, start_time, end_time = create_cross_item_test_data() write_cross_item_data_to_storage(all_items) @@ -154,7 +152,11 @@ def test_cross_item_query_sampling_disabled(self) -> None: storage_keys, storage_tracker = track_storage_selections() - with storage_tracker: + # Explicitly disable the feature flag for the duration of the query execution. + with ( + override_options("snuba", {"cross_item_queries_no_sample_outer": False}), + storage_tracker, + ): with patch.object(RPCEndpoint, "_RPCEndpoint__before_execute"): message = create_time_series_request( start_time=start_time, diff --git a/tests/web/rpc/v1/test_endpoint_trace_item_table/test_endpoint_trace_item_table_cross_item_sampling.py b/tests/web/rpc/v1/test_endpoint_trace_item_table/test_endpoint_trace_item_table_cross_item_sampling.py index ab3b41ee650..9b70489a2c3 100644 --- a/tests/web/rpc/v1/test_endpoint_trace_item_table/test_endpoint_trace_item_table_cross_item_sampling.py +++ b/tests/web/rpc/v1/test_endpoint_trace_item_table/test_endpoint_trace_item_table_cross_item_sampling.py @@ -2,6 +2,7 @@ from unittest.mock import patch import pytest +from sentry_options.testing import override_options from sentry_protos.snuba.v1.endpoint_trace_item_table_pb2 import ( Column, TraceItemTableRequest, @@ -13,7 +14,6 @@ from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey from sentry_protos.snuba.v1.trace_item_filter_pb2 import TraceItemFilter -from snuba import state from snuba.datasets.storages.storage_key import StorageKey from snuba.downsampled_storage_tiers import Tier from snuba.web.rpc import RPCEndpoint @@ -64,9 +64,6 @@ def test_cross_item_query_sampling_enabled(self) -> None: - The inner query uses downsampled storage (TIER_8) - The outer query uses full storage (EAP_ITEMS) """ - # Enable the feature flag - state.set_config("cross_item_queries_no_sample_outer", 1) - trace_ids, all_items, start_time, end_time = create_cross_item_test_data() write_cross_item_data_to_storage(all_items) @@ -83,7 +80,11 @@ def test_cross_item_query_sampling_enabled(self) -> None: storage_keys, storage_tracker = track_storage_selections() - with storage_tracker: + # Enable the feature flag for the duration of the query execution. + with ( + override_options("snuba", {"cross_item_queries_no_sample_outer": True}), + storage_tracker, + ): with patch.object(RPCEndpoint, "_RPCEndpoint__before_execute"): message = create_trace_item_table_request( start_time=start_time, @@ -123,9 +124,6 @@ def test_cross_item_query_sampling_disabled(self) -> None: Test that when cross_item_queries_no_sample_outer is disabled (default): - Both queries use the same storage tier """ - # Explicitly disable the feature flag - state.set_config("cross_item_queries_no_sample_outer", 0) - trace_ids, all_items, start_time, end_time = create_cross_item_test_data() write_cross_item_data_to_storage(all_items) @@ -138,7 +136,11 @@ def test_cross_item_query_sampling_disabled(self) -> None: storage_keys, storage_tracker = track_storage_selections() - with storage_tracker: + # Explicitly disable the feature flag for the duration of the query execution. + with ( + override_options("snuba", {"cross_item_queries_no_sample_outer": False}), + storage_tracker, + ): with patch.object(RPCEndpoint, "_RPCEndpoint__before_execute"): message = create_trace_item_table_request( start_time=start_time, diff --git a/tests/web/rpc/v1/test_storage_routing.py b/tests/web/rpc/v1/test_storage_routing.py index 792bf70d7a4..fd98f78f229 100644 --- a/tests/web/rpc/v1/test_storage_routing.py +++ b/tests/web/rpc/v1/test_storage_routing.py @@ -7,11 +7,11 @@ import pytest from google.protobuf.timestamp_pb2 import Timestamp from sentry_kafka_schemas import get_codec +from sentry_options.testing import override_options from sentry_protos.snuba.v1.downsampled_storage_pb2 import DownsampledStorageConfig from sentry_protos.snuba.v1.endpoint_time_series_pb2 import TimeSeriesRequest from sentry_protos.snuba.v1.request_common_pb2 import RequestMeta, TraceItemType -from snuba import state from snuba.configs.configuration import Configuration, ResourceIdentifier from snuba.datasets.storages.storage_key import StorageKey from snuba.downsampled_storage_tiers import Tier @@ -240,32 +240,28 @@ def test_routing_strategy_selects_tier_1_if_highest_accuracy_mode() -> None: @pytest.mark.redis_db def test_routing_decision_forced_downsample_killswitch() -> None: - state.set_config("default_tier", 8) - - try: - ts = Timestamp() - ts.GetCurrentTime() - tstart = Timestamp(seconds=ts.seconds - 3600) - in_msg = TimeSeriesRequest( - meta=RequestMeta( - request_id=RANDOM_REQUEST_ID, - project_ids=[1, 2, 3], - organization_id=1, - cogs_category="something", - referrer="something", - start_timestamp=tstart, - end_timestamp=ts, - trace_item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN, - ), - granularity_secs=60, - ) - routing_context = deepcopy(ROUTING_CONTEXT) - routing_context.in_msg = in_msg - in_msg.meta.downsampled_storage_config.mode = DownsampledStorageConfig.MODE_HIGHEST_ACCURACY + ts = Timestamp() + ts.GetCurrentTime() + tstart = Timestamp(seconds=ts.seconds - 3600) + in_msg = TimeSeriesRequest( + meta=RequestMeta( + request_id=RANDOM_REQUEST_ID, + project_ids=[1, 2, 3], + organization_id=1, + cogs_category="something", + referrer="something", + start_timestamp=tstart, + end_timestamp=ts, + trace_item_type=TraceItemType.TRACE_ITEM_TYPE_SPAN, + ), + granularity_secs=60, + ) + routing_context = deepcopy(ROUTING_CONTEXT) + routing_context.in_msg = in_msg + in_msg.meta.downsampled_storage_config.mode = DownsampledStorageConfig.MODE_HIGHEST_ACCURACY + with override_options("snuba", {"default_tier": 8}): routing_decision = AlwaysTier1RoutingStrategy().get_routing_decision(routing_context) - assert routing_decision.tier == Tier.TIER_8 - finally: - state.delete_config("default_tier") + assert routing_decision.tier == Tier.TIER_8 @pytest.mark.redis_db @@ -404,16 +400,23 @@ def test_get_time_budget() -> None: strategy = RoutingStrategySelectsTier8() # Test case 1: No config specified - should return default 8000 - assert strategy._get_time_budget_ms() == 8000 # Test case 2: Global config specified - should return global value - state.set_config("StorageRouting.time_budget_ms", 5000) - assert strategy._get_time_budget_ms() == 5000 - - # Test case 3: Strategy specific config specified - should return strategy value - state.set_config("RoutingStrategySelectsTier8.time_budget_ms", 3000) - assert strategy._get_time_budget_ms() == 3000 + with override_options("snuba", {"storage_routing_time_budget_ms": {"StorageRouting": 5000}}): + assert strategy._get_time_budget_ms() == 5000 + + # Test case 3: Strategy specific config takes precedence over the global one + with override_options( + "snuba", + { + "storage_routing_time_budget_ms": { + "StorageRouting": 5000, + "RoutingStrategySelectsTier8": 3000, + } + }, + ): + assert strategy._get_time_budget_ms() == 3000 @pytest.mark.redis_db @@ -422,6 +425,10 @@ class TooLongStrategy(RoutingStrategySelectsTier8): pass with ( + override_options( + "snuba", + {"storage_routing_time_budget_ms": {"OutcomesBasedRoutingStrategy": 8000}}, + ), mock.patch( "snuba.web.rpc.storage_routing.routing_strategies.storage_routing.record_query" ) as record_query, @@ -434,7 +441,6 @@ class TooLongStrategy(RoutingStrategySelectsTier8): return_value=get_query_result(12000), ), ): - state.set_config("OutcomesBasedRoutingStrategy.time_budget_ms", 8000) EndpointTimeSeries().execute(_get_in_msg()) recorded_payload = record_query.mock_calls[0].args[0] assert recorded_payload["query_list"][0]["stats"]["extra_info"][ @@ -452,6 +458,10 @@ class TooFastStrategy(RoutingStrategySelectsTier8): pass with ( + override_options( + "snuba", + {"storage_routing_time_budget_ms": {"OutcomesBasedRoutingStrategy": 8000}}, + ), mock.patch( "snuba.web.rpc.storage_routing.routing_strategies.storage_routing.record_query" ) as record_query, @@ -464,7 +474,6 @@ class TooFastStrategy(RoutingStrategySelectsTier8): return_value=get_query_result(900), ), ): - state.set_config("OutcomesBasedRoutingStrategy.time_budget_ms", 8000) EndpointTimeSeries().execute(_get_in_msg()) recorded_payload = record_query.mock_calls[0].args[0] assert recorded_payload["query_list"][0]["stats"]["extra_info"][ diff --git a/tests/web/test_bulk_delete_query.py b/tests/web/test_bulk_delete_query.py index 2fa54bc7de8..835beb4fe07 100644 --- a/tests/web/test_bulk_delete_query.py +++ b/tests/web/test_bulk_delete_query.py @@ -8,6 +8,7 @@ import rapidjson from confluent_kafka import Consumer from confluent_kafka.admin import AdminClient +from sentry_options.testing import override_options from sentry_protos.snuba.v1.trace_item_attribute_pb2 import AttributeKey from snuba import settings @@ -16,7 +17,6 @@ from snuba.datasets.storages.storage_key import StorageKey from snuba.lw_deletions.types import AttributeConditions from snuba.query.exceptions import InvalidQueryException -from snuba.state import set_config from snuba.utils.manage_topics import create_topics from snuba.utils.streams.configuration_builder import get_default_kafka_configuration from snuba.utils.streams.topics import Topic @@ -97,12 +97,12 @@ def test_deletes_not_enabled_on_storage() -> None: @pytest.mark.redis_db +@override_options("snuba", {"storage_deletes_enabled": False}) def test_deletes_not_enabled_runtime_config() -> None: storage = get_writable_storage(StorageKey("search_issues")) conditions = {"project_id": [1], "group_id": [1, 2, 3, 4]} attr_info = get_attribution_info() - set_config("storage_deletes_enabled", 0) with pytest.raises(DeletesNotEnabledError): delete_from_storage(storage, conditions, attr_info) @@ -110,12 +110,12 @@ def test_deletes_not_enabled_runtime_config() -> None: @pytest.mark.redis_db @patch("snuba.web.bulk_delete_query._enforce_max_rows", return_value=10) @patch("snuba.web.bulk_delete_query.produce_delete_query") +@override_options("snuba", {"lw_deletes_killswitch": {"search_issues": "[1]"}}) def test_deletes_killswitch(mock_produce_query: Mock, mock_enforce_rows: Mock) -> None: storage = get_writable_storage(StorageKey("search_issues")) conditions = {"project_id": [1], "group_id": [1, 2, 3, 4]} attr_info = get_attribution_info() - set_config("lw_deletes_killswitch_search_issues", "[1]") delete_from_storage(storage, conditions, attr_info) mock_produce_query.assert_not_called() @@ -264,10 +264,7 @@ def test_attribute_conditions_feature_flag_enabled() -> None: ) attr_info = get_attribution_info() - # Enable the feature flag - set_config("permit_delete_by_attribute", 1) - - try: + with override_options("snuba", {"permit_delete_by_attribute": True}): # Mock out _enforce_max_rows to avoid needing actual data with patch("snuba.web.bulk_delete_query._enforce_max_rows", return_value=10): with patch("snuba.web.bulk_delete_query.produce_delete_query") as mock_produce: @@ -290,9 +287,6 @@ def test_attribute_conditions_feature_flag_enabled() -> None: } } assert call_args["attribute_conditions_item_type"] == TRACE_ITEM_TYPE_OCCURRENCE - finally: - # Clean up: disable the feature flag - set_config("permit_delete_by_attribute", 0) @pytest.mark.redis_db @@ -314,8 +308,7 @@ def test_eap_items_counts_each_table_against_its_readonly_replica() -> None: ) attr_info = get_attribution_info() - set_config("permit_delete_by_attribute", 1) - try: + with override_options("snuba", {"permit_delete_by_attribute": True}): with patch( "snuba.web.bulk_delete_query._enforce_max_rows", return_value=10 ) as mock_enforce: @@ -337,8 +330,6 @@ def test_eap_items_counts_each_table_against_its_readonly_replica() -> None: "eap_items_downsample_64_ro", "eap_items_downsample_512_ro", } - finally: - set_config("permit_delete_by_attribute", 0) def test_count_storage_key_mapping_without_readonly_storage_set() -> None: diff --git a/tests/web/test_db_query.py b/tests/web/test_db_query.py index 6b5290e2702..bcd264fdf04 100644 --- a/tests/web/test_db_query.py +++ b/tests/web/test_db_query.py @@ -1,11 +1,12 @@ from __future__ import annotations -from typing import Any, Mapping, MutableMapping, Optional +import json +from typing import Any, Mapping, MutableMapping, Optional, cast from unittest import mock import pytest +from sentry_options.testing import override_options -from snuba import state from snuba.attribution.appid import AppID from snuba.attribution.attribution_info import AttributionInfo from snuba.clickhouse.formatter.query import format_query @@ -192,8 +193,40 @@ ] +def _query_config_to_overrides(query_config: Mapping[str, Any]) -> dict[str, Any]: + """Translate the legacy flat runtime-config keys used by these test cases + into the sentry-options dict shape _get_query_settings_from_config now reads. + Values are stringified to match the string-typed option dicts; the + per-prefix/per-referrer second level is JSON-encoded.""" + base: dict[str, str] = {} + async_settings: dict[str, str] = {} + by_prefix: dict[str, dict[str, str]] = {} + by_referrer: dict[str, dict[str, str]] = {} + for key, value in query_config.items(): + sval = str(value) + if key.startswith("query_settings/"): + base[key.split("/", 1)[1]] = sval + elif key.startswith("async_query_settings/"): + async_settings[key.split("/", 1)[1]] = sval + elif key.startswith("referrer/"): + _, ref, _, setting = key.split("/", 3) + by_referrer.setdefault(ref, {})[setting] = sval + else: + prefix, _, setting = key.split("/", 2) + by_prefix.setdefault(prefix, {})[setting] = sval + overrides: dict[str, Any] = {} + if base: + overrides["query_settings"] = base + if async_settings: + overrides["async_query_settings"] = async_settings + if by_prefix: + overrides["query_settings_by_prefix"] = {p: json.dumps(s) for p, s in by_prefix.items()} + if by_referrer: + overrides["query_settings_by_referrer"] = {r: json.dumps(s) for r, s in by_referrer.items()} + return overrides + + @pytest.mark.parametrize("query_config,expected,query_prefix,async_override,referrer", test_data) -@pytest.mark.redis_db def test_query_settings_from_config( query_config: Mapping[str, Any], expected: MutableMapping[str, Any], @@ -201,11 +234,11 @@ def test_query_settings_from_config( async_override: bool, referrer: str, ) -> None: - for k, v in query_config.items(): - state.set_config(k, v) - assert ( - _get_query_settings_from_config(query_prefix, async_override, referrer=referrer) == expected - ) + with override_options("snuba", _query_config_to_overrides(query_config)): + result = _get_query_settings_from_config(query_prefix, async_override, referrer=referrer) + # Values come back as strings (the option dicts are string-typed); ClickHouse + # HTTP settings are strings on the wire regardless. + assert result == {k: str(v) for k, v in expected.items()} def _build_test_query( @@ -397,8 +430,6 @@ def test_bypass_cache_referrer() -> None: query_metadata_list: list[ClickhouseQueryMetadata] = [] stats: dict[str, Any] = {"clickhouse_table": "errors_local"} - state.set_config("enable_bypass_cache_referrers", 1) - attribution_info = AttributionInfo( app_id=AppID(key="key"), tenant_ids={ @@ -413,7 +444,10 @@ def test_bypass_cache_referrer() -> None: # cache should not be used for "some_bypass_cache_referrer" so if the # bypass does not work, the test will try to use a bad cache - with mock.patch("snuba.settings.BYPASS_CACHE_REFERRERS", ["some_bypass_cache_referrer"]): + with ( + override_options("snuba", {"enable_bypass_cache_referrers": True}), + mock.patch("snuba.settings.BYPASS_CACHE_REFERRERS", ["some_bypass_cache_referrer"]), + ): with mock.patch("snuba.web.db_query._get_cache_partition"): result = db_query( clickhouse_query=query, @@ -464,8 +498,10 @@ def test_db_query_fail() -> None: assert len(query_metadata_list) == 1 assert query_metadata_list[0].status.value == "error" - assert excinfo.value.extra["stats"] == stats - assert excinfo.value.extra["sql"] is not None + err = cast(QueryException, excinfo.value) + assert isinstance(err, QueryException) + assert err.extra["stats"] == stats + assert err.extra["sql"] is not None class MockThrottleAllocationPolicy(AllocationPolicy): @@ -700,14 +736,16 @@ def _update_quota_balance( }, } # extra data contains policy failure information + err = cast(QueryException, excinfo.value) + assert isinstance(err, QueryException) assert ( - excinfo.value.extra["stats"]["quota_allowance"]["details"]["RejectAllocationPolicy"][ + err.extra["stats"]["quota_allowance"]["details"]["RejectAllocationPolicy"][ "explanation" ]["reason"] == "policy rejects all queries" ) assert query_metadata_list[0].request_status.status.value == "rate-limited" - cause = excinfo.value.__cause__ + cause = err.__cause__ assert isinstance(cause, AllocationPolicyViolations) assert "RejectAllocationPolicy" in cause.violations assert update_called, ( @@ -906,7 +944,9 @@ def _run_query() -> None: with pytest.raises(QueryException) as e: _run_query() - assert e.value.extra["stats"]["quota_allowance"] == { + err = cast(QueryException, e.value) + assert isinstance(err, QueryException) + assert err.extra["stats"]["quota_allowance"] == { "summary": { "threads_used": 0, "is_successful": False, @@ -942,7 +982,7 @@ def _run_query() -> None: }, }, } - cause = e.value.__cause__ + cause = err.__cause__ assert isinstance(cause, AllocationPolicyViolations) assert "CountQueryPolicy" in cause.violations assert "CountQueryPolicyDuplicate" not in cause.violations @@ -994,9 +1034,9 @@ def test_clickhouse_settings_applied_to_query() -> None: @pytest.mark.events_db @pytest.mark.redis_db +@override_options("snuba", {"ignore_consistent_queries_sample_rate": {"events": 1.0}}) def test_db_query_ignore_consistent() -> None: query, storage, attribution_info = _build_test_query("count(distinct(project_id))") - state.set_config("events_ignore_consistent_queries_sample_rate", 1) query_metadata_list: list[ClickhouseQueryMetadata] = [] stats: dict[str, Any] = {} diff --git a/tests/web/test_max_rows_enforcer.py b/tests/web/test_max_rows_enforcer.py index 729f8d19e99..8e1df3c283a 100644 --- a/tests/web/test_max_rows_enforcer.py +++ b/tests/web/test_max_rows_enforcer.py @@ -1,8 +1,9 @@ from datetime import datetime -from typing import Any, Callable +from typing import Any, Callable, Generator from unittest import mock import pytest +from sentry_options.testing import override_options from snuba.clickhouse.columns import ColumnSet from snuba.clickhouse.query import Query @@ -14,7 +15,6 @@ from snuba.query.data_source.simple import Table from snuba.query.dsl import and_cond, column, equals, literal from snuba.query.exceptions import TooManyDeleteRowsException -from snuba.state import set_config from snuba.web.delete_query import _enforce_max_rows from tests.base import BaseApiTest from tests.web.rpc.v1.test_utils import write_eap_item @@ -43,8 +43,12 @@ def setup_method(self, test_method: Callable[..., Any]) -> None: is_delete=True, ) + @pytest.fixture(autouse=True) + def _short_circuit_cache(self) -> Generator[None, None, None]: + with override_options("snuba", {"read_through_cache.short_circuit": True}): + yield + def _insert_event(self) -> None: - set_config("read_through_cache.short_circuit", 1) now = datetime.now().replace(minute=0, second=0, microsecond=0) write_eap_item( @@ -86,7 +90,7 @@ def test_max_row_enforcer_rejects(self, mock: mock.MagicMock) -> None: allowed_columns=["project_id", "organization_id"], ), ) + @override_options("snuba", {"enforce_max_rows_to_delete": False}) def test_bypass_enforce_max_rows(self, mock: mock.MagicMock) -> None: - set_config("enforce_max_rows_to_delete", 0) self._insert_event() _enforce_max_rows(self.query) diff --git a/uv.lock b/uv.lock index 0e7adb5d589..5f30df0654d 100644 --- a/uv.lock +++ b/uv.lock @@ -933,6 +933,16 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_kafka_schemas-2.1.36-py2.py3-none-any.whl", hash = "sha256:f3f5c1aa16e9972840d06038c7085a5e1bd50e1fdbdbf42e99b33ec56ccb4ace" }, ] +[[package]] +name = "sentry-options" +version = "1.1.1" +source = { registry = "https://pypi.devinfra.sentry.io/simple" } +wheels = [ + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.1.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:a552680a15d348be7c4748a91931e6fb1d16bcc3945327761a46765ba272e0ab" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.1.1-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c98569da06dc000bde0908e538015d359ac84fa718dc8e4763750b1092d092bf" }, + { url = "https://pypi.devinfra.sentry.io/wheels/sentry_options-1.1.1-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0738a2c2ad68a4b56654d8a1d9304506fda372eb93c65f70074547605ac2d91f" }, +] + [[package]] name = "sentry-protos" version = "0.34.0" @@ -1072,6 +1082,7 @@ dependencies = [ { name = "sentry-arroyo", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-conventions", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-kafka-schemas", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, + { name = "sentry-options", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-protos", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-redis-tools", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, { name = "sentry-relay", marker = "sys_platform == 'darwin' or sys_platform == 'linux'" }, @@ -1141,6 +1152,7 @@ requires-dist = [ { name = "sentry-arroyo", specifier = ">=2.39.2" }, { name = "sentry-conventions", specifier = ">=0.13.0" }, { name = "sentry-kafka-schemas", specifier = ">=2.1.36" }, + { name = "sentry-options", specifier = ">=1.1.1" }, { name = "sentry-protos", specifier = ">=0.34.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.1" }, { name = "sentry-relay", specifier = ">=0.9.25" },