From 270e6aeaa92ad14ad7edc51d3f673ae24446104e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 22:52:06 +0000 Subject: [PATCH 01/32] ref(options): migrate enable_any_attribute_filter to sentry-options Adds the sentry-options Python client and uses it for the `enable_any_attribute_filter` flag, which previously lived in Redis-backed runtime config (`state.get_int_config`). This mirrors how the Rust consumers already read the `snuba` options namespace. - Add `sentry-options>=1.1.1` dependency (uv.lock updated) - Declare `enable_any_attribute_filter` (boolean, default true) in the snuba sentry-options schema - Add `snuba/state/sentry_options.py` wrapping `init()` / `options("snuba").get()` with a safe fallback to each call site's default; initialized from `setup_sentry()` - Swap the RPC call site to `get_option(...)`, preserving the default-on kill-switch semantics - Add unit + integration tests; point conftest at the in-repo schema via SENTRY_OPTIONS_DIR so init() is cwd-independent Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- pyproject.toml | 1 + sentry-options/schemas/snuba/schema.json | 5 ++ snuba/environment.py | 4 ++ snuba/state/sentry_options.py | 64 ++++++++++++++++++++++++ snuba/web/rpc/common/common.py | 3 +- tests/conftest.py | 8 +++ tests/state/test_sentry_options.py | 23 +++++++++ tests/web/rpc/test_common.py | 29 +++++++++++ uv.lock | 12 +++++ 9 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 snuba/state/sentry_options.py create mode 100644 tests/state/test_sentry_options.py diff --git a/pyproject.toml b/pyproject.toml index 5e089e788d7..bf2c0623ed6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dependencies = [ "sentry-arroyo>=2.39.2", "sentry-conventions>=0.12.0", "sentry-kafka-schemas>=2.1.35", + "sentry-options>=1.1.1", "sentry-protos>=0.32.0", "sentry-redis-tools>=0.5.1", "sentry-relay>=0.9.25", diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 2ea86bd21c4..80f9eddd078 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -26,6 +26,11 @@ "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)." } } } diff --git a/snuba/environment.py b/snuba/environment.py index b8311e27540..8f10626cd3a 100644 --- a/snuba/environment.py +++ b/snuba/environment.py @@ -125,6 +125,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/state/sentry_options.py b/snuba/state/sentry_options.py new file mode 100644 index 00000000000..6adaf81f33d --- /dev/null +++ b/snuba/state/sentry_options.py @@ -0,0 +1,64 @@ +"""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: + return default diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index d196a66e802..885d61f16ac 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -47,6 +47,7 @@ Lambda, SubscriptableReference, ) +from snuba.state.sentry_options import get_option from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException @@ -1078,7 +1079,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_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/tests/conftest.py b/tests/conftest.py index 48dfe33c5c9..2e36359c521 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,6 +47,14 @@ 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() succeeds regardless + # of the working directory tests are launched from. (Without this it relies + # on the ./sentry-options relative fallback.) + os.environ.setdefault( + "SENTRY_OPTIONS_DIR", + os.path.join(os.path.dirname(__file__), os.pardir, "sentry-options"), + ) + initialize_snuba() setup_sentry() initialize_snuba() diff --git a/tests/state/test_sentry_options.py b/tests/state/test_sentry_options.py new file mode 100644 index 00000000000..8c542bfd002 --- /dev/null +++ b/tests/state/test_sentry_options.py @@ -0,0 +1,23 @@ +from sentry_options.testing import override_options + +from snuba.state.sentry_options import SNUBA_OPTIONS_NAMESPACE, get_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" diff --git a/tests/web/rpc/test_common.py b/tests/web/rpc/test_common.py index d6ce06c540b..f49999afce3 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, @@ -1082,3 +1083,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/uv.lock b/uv.lock index 760487918eb..a37bf392f3c 100644 --- a/uv.lock +++ b/uv.lock @@ -933,6 +933,16 @@ wheels = [ { url = "https://pypi.devinfra.sentry.io/wheels/sentry_kafka_schemas-2.1.35-py2.py3-none-any.whl", hash = "sha256:067da6d401082cc5948005211179a58c5d5b9d559c6ce60b82eac47e02d31a5d" }, ] +[[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.32.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.12.0" }, { name = "sentry-kafka-schemas", specifier = ">=2.1.35" }, + { name = "sentry-options", specifier = ">=1.1.1" }, { name = "sentry-protos", specifier = ">=0.32.0" }, { name = "sentry-redis-tools", specifier = ">=0.5.1" }, { name = "sentry-relay", specifier = ">=0.9.25" }, From 16891b43a7fc7589fc22fd9b8bf9ec60f38ed7d1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 23:40:43 +0000 Subject: [PATCH 02/32] ref(options): migrate read-only RPC/query runtime configs to sentry-options Continues the migration of Redis-backed runtime config to sentry-options (the Python counterpart to how the Rust consumers already read the `snuba` namespace). Migrates 12 read-only feature flags / tuning knobs that have a single source of truth and are safe to manage centrally: boolean: aggregation_deprecation_enabled, enable_trace_pagination, use.low.cardinality.processor, cross_item_queries_no_sample_outer integer: default_tier, export_trace_items_default_page_size, use_sampling_factor_timestamp_seconds, ExecutionStage.max_query_size_bytes number: EndpointGetTrace.apply_final_rollout_percentage, rpc_logging_sample_rate, rpc_logging_flush_logs string: ExecutionStage.disable_max_query_size_check_for_clusters - Add typed accessors get_bool_option/get_int_option/get_float_option/ get_str_option to snuba/state/sentry_options.py (mirroring get_int_config & friends) so call sites stay typed under strict mypy. Each falls back to the call site default if sentry-options is unavailable, matching the Rust `.ok()...unwrap_or(default)` semantics. - Declare each key in the snuba sentry-options schema with the type and default matching the previous runtime-config default (behavior-preserving). - Swap each call site from state.get_*_config(...) to the typed accessor. - Update tests that toggled these via state.set_config(...) to use sentry_options.testing.override_options(...) instead. Schema defaults match prior get_*_config defaults, so behavior is unchanged until a value is set in sentry-options-automator. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 60 +++++++++++++++++++ snuba/pipeline/stages/query_execution.py | 12 ++-- .../logical/low_cardinality_processor.py | 4 +- snuba/state/sentry_options.py | 51 ++++++++++++++++ snuba/web/rpc/__init__.py | 11 ++-- snuba/web/rpc/common/common.py | 15 ++--- .../routing_strategies/storage_routing.py | 3 +- .../web/rpc/v1/endpoint_export_trace_items.py | 4 +- snuba/web/rpc/v1/endpoint_get_trace.py | 10 ++-- .../R_eap_items/resolver_time_series.py | 8 +-- .../R_eap_items/resolver_trace_item_table.py | 6 +- tests/pipeline/test_execution_stage.py | 47 ++++++++------- tests/state/test_sentry_options.py | 40 ++++++++++++- tests/web/rpc/test_common.py | 6 +- tests/web/rpc/v1/test_endpoint_get_trace.py | 33 ++++------ ...ndpoint_time_series_cross_item_sampling.py | 20 ++++--- ...nt_trace_item_table_cross_item_sampling.py | 20 ++++--- tests/web/rpc/v1/test_storage_routing.py | 47 +++++++-------- 18 files changed, 266 insertions(+), 131 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 80f9eddd078..e4ce2b11942 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -31,6 +31,66 @@ "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." } } } diff --git a/snuba/pipeline/stages/query_execution.py b/snuba/pipeline/stages/query_execution.py index b7b200984ea..c4c73da193e 100644 --- a/snuba/pipeline/stages/query_execution.py +++ b/snuba/pipeline/stages/query_execution.py @@ -9,7 +9,7 @@ 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(",") ) 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/state/sentry_options.py b/snuba/state/sentry_options.py index 6adaf81f33d..9c95e7f216f 100644 --- a/snuba/state/sentry_options.py +++ b/snuba/state/sentry_options.py @@ -62,3 +62,54 @@ def get_option(key: str, default: OptionValue) -> OptionValue: return sentry_options.options(SNUBA_OPTIONS_NAMESPACE).get(key) except sentry_options.OptionsError: 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 below only guards against a misconfigured + value and otherwise falls back to ``default``. + """ + value = get_option(key, default) + 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 get_int_option(key: str, default: int) -> int: + """Read ``key`` as an int. Counterpart to ``state.get_int_config``.""" + value = get_option(key, default) + 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 get_float_option(key: str, default: float) -> float: + """Read ``key`` as a float. Counterpart to ``state.get_float_config``.""" + value = get_option(key, default) + 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 get_str_option(key: str, default: str) -> str: + """Read ``key`` as a str. Counterpart to ``state.get_str_config``.""" + value = get_option(key, default) + if isinstance(value, str): + return value + return default 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 885d61f16ac..2cf2161301a 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, @@ -47,7 +47,7 @@ Lambda, SubscriptableReference, ) -from snuba.state.sentry_options import get_option +from snuba.state.sentry_options import get_int_option, get_option from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException @@ -236,12 +236,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 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..914b555ebd5 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py @@ -52,6 +52,7 @@ 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_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 +316,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 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/resolvers/R_eap_items/resolver_time_series.py b/snuba/web/rpc/v1/resolvers/R_eap_items/resolver_time_series.py index 5e03379def2..63facb5ef75 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 ( @@ -488,7 +488,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( @@ -500,8 +500,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 caa9cd2da9f..2d80a0735ae 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 ( @@ -751,8 +751,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/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/state/test_sentry_options.py b/tests/state/test_sentry_options.py index 8c542bfd002..4c86149cfb0 100644 --- a/tests/state/test_sentry_options.py +++ b/tests/state/test_sentry_options.py @@ -1,6 +1,13 @@ from sentry_options.testing import override_options -from snuba.state.sentry_options import SNUBA_OPTIONS_NAMESPACE, get_option +from snuba.state.sentry_options import ( + SNUBA_OPTIONS_NAMESPACE, + get_bool_option, + get_float_option, + get_int_option, + get_option, + get_str_option, +) def test_get_option_returns_schema_default() -> None: @@ -21,3 +28,34 @@ 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" diff --git a/tests/web/rpc/test_common.py b/tests/web/rpc/test_common.py index f49999afce3..1399d43007b 100644 --- a/tests/web/rpc/test_common.py +++ b/tests/web/rpc/test_common.py @@ -89,9 +89,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))) class TestTraceItemFiltersArrayLike: 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_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..7ed9bf52498 100644 --- a/tests/web/rpc/v1/test_storage_routing.py +++ b/tests/web/rpc/v1/test_storage_routing.py @@ -7,6 +7,7 @@ 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 @@ -240,32 +241,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 From 3462c2be937fe6425e358a7abd0e431c8e15aeba Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 23:51:53 +0000 Subject: [PATCH 03/32] ref(options): migrate static Rust consumer runtime configs to sentry-options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Rust consumers read some config via `runtime_config::get_str_config`, which calls back into Python `snuba.state` (Redis) over PyO3. Migrate the two static, non-parameterized boolean killswitches to sentry-options instead, matching how the Rust consumers already read the `snuba` namespace (see blq_router.rs). This also removes their PyO3/Redis round-trip. - eap_items_drop_invalid_timestamps (utils.rs): drop messages with event timestamps >1 week future / >30 days past. - experimental_healthcheck (healthcheck.rs): treat commit-request progress as healthy. Both are declared in the snuba sentry-options schema (boolean, default false) and read via `options("snuba").get(key).as_bool()` with a fallback to false, identical to the existing BLQ pattern. Healthcheck tests now use `sentry_options::testing::override_options` instead of `runtime_config::patch_str_config_for_test`. Not migrated (left on runtime config): the per-storage / per-consumer-group parameterized keys (clickhouse_load_balancing:, clickhouse_max_insert_block_size:, eap_items_dlq_grace_period_min:, quantized_rebalance_consumer_group_delay_secs__) and the string-valued generic_metrics_use_case_killswitch — dynamic keys cannot be declared in a static sentry-options schema. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- rust_snuba/src/processors/utils.rs | 10 ++++----- rust_snuba/src/strategies/healthcheck.rs | 28 +++++++++++++++++------- sentry-options/schemas/snuba/schema.json | 10 +++++++++ 3 files changed, 35 insertions(+), 13 deletions(-) diff --git a/rust_snuba/src/processors/utils.rs b/rust_snuba/src/processors/utils.rs index 1b19356d4c9..0a2545ecfad 100644 --- a/rust_snuba/src/processors/utils.rs +++ b/rust_snuba/src/processors/utils.rs @@ -1,6 +1,6 @@ use crate::config::EnvConfig; -use crate::runtime_config::get_str_config; use crate::types::item_type_name; +use sentry_options::options; use chrono::{DateTime, NaiveDateTime, Utc}; use schemars::JsonSchema; use sentry_arroyo::counter; @@ -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/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 e4ce2b11942..a457b07b656 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -91,6 +91,16 @@ "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." } } } From 88bbe22867df94d3a6c0a8091ecb08a3fc73f874 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 23 Jun 2026 23:58:50 +0000 Subject: [PATCH 04/32] ref(options): migrate db_query cache flags to sentry-options Migrates the read-only query-cache feature flags read in web/db_query.py: enable_cache_partitioning (bool, default true), randomize_query_id (bool, default false), retry_duplicate_query_id (bool, default false), and enable_bypass_cache_referrers (bool, default false). Swaps the call sites to get_bool_option and converts the one test toggle to override_options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 20 ++++++++++++++++++++ snuba/web/db_query.py | 11 ++++++----- tests/web/test_db_query.py | 8 +++++--- 3 files changed, 31 insertions(+), 8 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index a457b07b656..07d5e0f68cd 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -101,6 +101,26 @@ "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." } } } diff --git a/snuba/web/db_query.py b/snuba/web/db_query.py index ff8344330a6..a365ebd6709 100644 --- a/snuba/web/db_query.py +++ b/snuba/web/db_query.py @@ -58,6 +58,7 @@ ) from snuba.state.quota import ResourceQuota from snuba.state.rate_limit import RateLimitExceeded +from snuba.state.sentry_options import get_bool_option from snuba.util import force_bytes from snuba.utils.codecs import ExceptionAwareCodec from snuba.utils.metrics.timer import Timer @@ -201,7 +202,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 +236,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 +255,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 +292,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 diff --git a/tests/web/test_db_query.py b/tests/web/test_db_query.py index 6b5290e2702..fa92576fc0a 100644 --- a/tests/web/test_db_query.py +++ b/tests/web/test_db_query.py @@ -4,6 +4,7 @@ from unittest import mock import pytest +from sentry_options.testing import override_options from snuba import state from snuba.attribution.appid import AppID @@ -397,8 +398,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 +412,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, From fd5e12d85d391a3463cf5981e4a84d5ee8f51555 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 00:38:57 +0000 Subject: [PATCH 05/32] ref(options): harden get_option against unexpected client errors Aligns get_option with its docstring's "any reason" fallback contract. NotInitializedError/SchemaError/UnknownNamespaceError/UnknownOptionError all subclass OptionsError and were already handled, but a non-OptionsError escaping the client would have propagated into hot query paths. Catch and log those, returning the call-site default, and add a regression test. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- snuba/state/sentry_options.py | 12 ++++++++++++ tests/state/test_sentry_options.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/snuba/state/sentry_options.py b/snuba/state/sentry_options.py index 9c95e7f216f..8e9e35494b6 100644 --- a/snuba/state/sentry_options.py +++ b/snuba/state/sentry_options.py @@ -61,6 +61,18 @@ def get_option(key: str, default: OptionValue) -> OptionValue: 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 diff --git a/tests/state/test_sentry_options.py b/tests/state/test_sentry_options.py index 4c86149cfb0..e7d4ffeeb77 100644 --- a/tests/state/test_sentry_options.py +++ b/tests/state/test_sentry_options.py @@ -1,3 +1,5 @@ +from unittest import mock + from sentry_options.testing import override_options from snuba.state.sentry_options import ( @@ -59,3 +61,18 @@ def test_typed_accessors_fall_back_on_unknown_option() -> None: 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" From 5edba9d984b3dfa3c9f63ca551b4fd111172d09a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 00:45:21 +0000 Subject: [PATCH 06/32] ref(options): migrate clickhouse-http and utils config knobs Migrates seven read-only operational knobs to sentry-options: - debug_buffer_size_bytes, http_batch_join_timeout (clickhouse/http.py) - project_quota_time_percentage, counter_window_size_minutes, allows_skipping_single_project_replacements (utils/bucket_timer.py) - use_sentry_metrics (utils/metrics/backends/dualwrite.py) - ondemand_profiler_hostnames (utils/profiler.py) None has a test toggle. debug_buffer_size_bytes maps to integer default 0 because the downstream check is `size < (value or 0)`, so None and 0 were already equivalent; the redundant isinstance assert is dropped. simultaneous_queries_sleep_seconds (read at two sites with different defaults) and optimize_parallel_threads (caller-supplied default) are intentionally left on runtime config: a single schema default cannot preserve their semantics. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 35 +++++++++++++++++++++++ snuba/clickhouse/http.py | 13 +++------ snuba/utils/bucket_timer.py | 14 ++++----- snuba/utils/metrics/backends/dualwrite.py | 4 +-- snuba/utils/profiler.py | 5 ++-- 5 files changed, 48 insertions(+), 23 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 07d5e0f68cd..944db0c6b77 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -121,6 +121,41 @@ "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." + }, + "http_batch_join_timeout": { + "type": "integer", + "default": 10, + "description": "Seconds to wait when joining a ClickHouse HTTP write batch before raising and shutting the consumer down." + }, + "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." } } } diff --git a/snuba/clickhouse/http.py b/snuba/clickhouse/http.py index 2b32dd5dd0c..2553062dffa 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,9 +348,7 @@ 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 - ) + batch_join_timeout = get_int_option("http_batch_join_timeout", 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. batch.join(timeout=batch_join_timeout) 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 From 68f61daf71a8494e7dad63fc80b5fced5c0fe1f3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 00:52:15 +0000 Subject: [PATCH 07/32] ref(options): migrate query-processor and RPC flags to sentry-options Migrates seven read-only flags and converts their test toggles to override_options (using the context-manager-as-decorator form): - throw_on_uniq_select_and_having (uniq_in_select_and_having) - function-validator.enabled (query/validation/functions) - mandatory_condition_enforce (conditions_enforcer) - eap.reject_string_timestamp_filters (time_series_request_visitor) - trace_ids_cross_item_query_limit (cross_item_queries) - storage_routing.enable_get_cluster_loadinfo (storage_routing) - max_spans_per_transaction (transactions_processor) The max_spans_per_transaction try/except + isinstance assert is dropped since get_int_option already coerces and falls back; mandatory_condition_enforce and eap.reject_string_timestamp_filters become real booleans. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 35 +++++++ .../processors/transactions_processor.py | 9 +- .../physical/conditions_enforcer.py | 4 +- .../physical/uniq_in_select_and_having.py | 4 +- snuba/query/validation/functions.py | 5 +- .../routing_strategies/storage_routing.py | 4 +- .../v1/resolvers/common/cross_item_queries.py | 4 +- .../visitors/time_series_request_visitor.py | 4 +- tests/datasets/test_transaction_processor.py | 96 ++++++------------- .../query/parser/validation/test_functions.py | 6 +- .../test_mandatory_condition_enforcer.py | 4 +- .../test_uniq_in_select_and_having.py | 6 +- 12 files changed, 89 insertions(+), 92 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 944db0c6b77..341e34f707a 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -156,6 +156,41 @@ "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." } } } 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/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/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/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/web/rpc/storage_routing/routing_strategies/storage_routing.py b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py index 914b555ebd5..635ba02db79 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/storage_routing.py @@ -52,7 +52,7 @@ 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_int_option +from snuba.state.sentry_options import get_bool_option, get_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 @@ -510,7 +510,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 ) 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 b51ddf6adef..223badbb333 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 @@ -153,7 +153,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/datasets/test_transaction_processor.py b/tests/datasets/test_transaction_processor.py index c5bd2695cc3..c016ae05261 100644 --- a/tests/datasets/test_transaction_processor.py +++ b/tests/datasets/test_transaction_processor.py @@ -6,6 +6,7 @@ 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 @@ -221,9 +221,7 @@ def build_result(self, meta: KafkaMessageMetadata) -> Mapping[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 = { "deleted": 0, @@ -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,9 +422,7 @@ 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 ) @@ -447,12 +431,8 @@ def test_missing_transaction_source(self) -> None: # 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 actual_message.rows[0]["transaction_source"] == "" def test_app_ctx_none(self) -> None: @@ -462,9 +442,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 +456,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 +469,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 +483,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 +495,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 +511,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 +520,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 +534,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/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_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_uniq_in_select_and_having.py b/tests/query/processors/test_uniq_in_select_and_having.py index 6a9a46a3c5e..ac70e8839f4 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,7 @@ from copy import deepcopy 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,7 +10,6 @@ UniqInSelectAndHavingProcessor, ) from snuba.query.query_settings import HTTPQuerySettings -from snuba.state import set_config from tests.query.processors.query_builders import build_query @@ -81,16 +81,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 From 43b439d5808fad1ac12f26aa92571bd33e04dd69 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:06:58 +0000 Subject: [PATCH 08/32] ref(options): migrate storage-selector and consumer flags to sentry-options Migrates six read-only flags and converts their test toggles to override_options (autouse fixtures become override_options yield-fixtures; function-scoped toggles use the decorator form): - admin.querylog_threads (admin/clickhouse/querylog.py) - enable_eap_readonly_table (storage_selectors/eap_items.py) - enable_events_readonly_table (storage_selectors/errors.py) - use_cross_item_path_for_single_item_queries (endpoint_get_traces.py) - executor_queue_size_factor (subscriptions/executor_consumer.py) - snuba_api_cogs_probability (querylog/__init__.py) admin.querylog_threads now reads via get_int_option, which always returns a valid int, so the BadThreadsValue path (and its now-unreachable test) is removed. Also fixes two pre-existing E712 lint errors in touched files. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 30 ++++++++++++++ snuba/admin/clickhouse/querylog.py | 19 ++------- .../entities/storage_selectors/eap_items.py | 4 +- .../entities/storage_selectors/errors.py | 4 +- snuba/querylog/__init__.py | 5 ++- snuba/subscriptions/executor_consumer.py | 5 +-- snuba/web/rpc/v1/endpoint_get_traces.py | 4 +- tests/admin/clickhouse/test_querylog.py | 29 +++---------- .../storage_selectors/test_eap_items.py | 41 ++++++++----------- .../entities/storage_selectors/test_errors.py | 17 ++++---- tests/datasets/test_events.py | 5 +-- tests/subscriptions/test_executor_consumer.py | 5 +-- tests/test_api.py | 9 ++-- tests/test_discover_api.py | 11 ++--- tests/test_snql_api.py | 12 +++--- tests/web/rpc/v1/test_endpoint_get_traces.py | 11 +++-- 16 files changed, 99 insertions(+), 112 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 341e34f707a..4de467b1405 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -191,6 +191,36 @@ "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." } } } 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/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/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/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/web/rpc/v1/endpoint_get_traces.py b/snuba/web/rpc/v1/endpoint_get_traces.py index 7096690d012..43cbbebaa1b 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 ( @@ -505,7 +505,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/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/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/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/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/test_api.py b/tests/test_api.py index f0a589360a4..28c52f4d507 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 @@ -1490,7 +1490,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 +1961,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_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_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/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 From c4300a332ca910ce035cfa9340ae6da9b6c0f5fa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:07:53 +0000 Subject: [PATCH 09/32] ref(options): keep http_batch_join_timeout on runtime config Revert the http_batch_join_timeout migration flagged by review. Its default is settings.BATCH_JOIN_TIMEOUT = int(os.environ.get("BATCH_JOIN_TIMEOUT", 10)), an env-var-derived value, not a constant. sentry-options returns the schema default when an option is unset, so deployments that raised the timeout only via the BATCH_JOIN_TIMEOUT env var would have silently dropped back to 10. Same class of issue as optimize_parallel_threads/simultaneous_queries_sleep_seconds, which were never migrated for the same reason. debug_buffer_size_bytes (constant default) stays on sentry-options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 5 ----- snuba/clickhouse/http.py | 6 ++++-- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 4de467b1405..0a65c239e1b 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -127,11 +127,6 @@ "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." }, - "http_batch_join_timeout": { - "type": "integer", - "default": 10, - "description": "Seconds to wait when joining a ClickHouse HTTP write batch before raising and shutting the consumer down." - }, "project_quota_time_percentage": { "type": "number", "default": 1.0, diff --git a/snuba/clickhouse/http.py b/snuba/clickhouse/http.py index 2553062dffa..fc5318af1fd 100644 --- a/snuba/clickhouse/http.py +++ b/snuba/clickhouse/http.py @@ -23,7 +23,7 @@ from urllib3.connectionpool import HTTPConnectionPool, HTTPSConnectionPool from urllib3.exceptions import HTTPError -from snuba import settings +from snuba import settings, state from snuba.clickhouse import DATETIME_FORMAT from snuba.clickhouse.errors import ClickhouseWriterError from snuba.clickhouse.formatter.expression import ClickhouseExpressionFormatter @@ -348,7 +348,9 @@ def write(self, values: Iterable[bytes]) -> None: batch.append(value) batch.close() - batch_join_timeout = get_int_option("http_batch_join_timeout", settings.BATCH_JOIN_TIMEOUT) + batch_join_timeout = state.get_config( + "http_batch_join_timeout", 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. batch.join(timeout=batch_join_timeout) From 65afd2b6537fcfc131c266e63856d9980dc7d5e3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:17:35 +0000 Subject: [PATCH 10/32] ref(options): migrate read-through cache flags to sentry-options Migrates cache_expiry_sec (int) and read_through_cache.short_circuit (bool) in state/cache/redis/backend.py. Converts the short_circuit test toggles to override_options across four test files: decorator form for function/method toggles, and a class-level autouse override_options yield-fixture in test_max_rows_enforcer where the flag was set in a shared _insert_event helper and had to persist for the whole test. Also fixes two pre-existing E712 lint errors and one latent mypy attr-defined error surfaced by touching these files. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 10 ++++++++++ snuba/state/cache/redis/backend.py | 8 ++++---- tests/state/test_cache.py | 8 +++++--- tests/test_api.py | 2 +- tests/test_search_issues_api.py | 3 ++- tests/web/test_max_rows_enforcer.py | 9 +++++++-- 6 files changed, 29 insertions(+), 11 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 0a65c239e1b..cf4a1b4a5f4 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -216,6 +216,16 @@ "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)." } } } 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/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/test_api.py b/tests/test_api.py index 28c52f4d507..19194a49feb 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1470,9 +1470,9 @@ 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}) 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}, diff --git a/tests/test_search_issues_api.py b/tests/test_search_issues_api.py index 804afa5de24..6c76cd8338b 100644 --- a/tests/test_search_issues_api.py +++ b/tests/test_search_issues_api.py @@ -5,6 +5,7 @@ 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 @@ -105,8 +106,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 diff --git a/tests/web/test_max_rows_enforcer.py b/tests/web/test_max_rows_enforcer.py index 729f8d19e99..1b9781c4730 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 @@ -43,8 +44,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( From c5d35d1b90c66e565ce639cdfa6aeaa6d3964a2f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:29:43 +0000 Subject: [PATCH 11/32] ref(options): migrate deletions subsystem flags to sentry-options Migrates nine read-only deletion knobs and converts their test toggles to override_options (decorators, a context-manager helper for the off-peak window tests, and with-blocks where a flag flips mid-test): - lw_deletions_offpeak_enabled/start/end (lw_deletions/off_peak.py) - org_ids_delete_allowlist, max_parts_mutating_for_delete (lw_deletions/strategy.py) - permit_delete_by_attribute (web/bulk_delete_query.py) - MAX_ONGOING_MUTATIONS_FOR_DELETE, storage_deletes_enabled, enforce_max_rows_to_delete (web/delete_query.py) settings.MAX_ONGOING_MUTATIONS_FOR_DELETE (5) and MAX_PARTS_MUTATING_FOR_DELETE (20) are constants, so the schema defaults match. lightweight_deletes_sync is intentionally left on runtime config: it uses `is not None` to decide whether to set the ClickHouse setting at all, which a typed scalar option cannot express. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 45 ++++++++++++++++++++++++ snuba/lw_deletions/off_peak.py | 8 ++--- snuba/lw_deletions/strategy.py | 14 +++----- snuba/web/bulk_delete_query.py | 5 +-- snuba/web/delete_query.py | 14 +++----- tests/lw_deletions/test_lw_deletions.py | 10 +++--- tests/lw_deletions/test_off_peak.py | 45 ++++++++++++------------ tests/test_search_issues_api.py | 31 ++++++++-------- tests/web/test_bulk_delete_query.py | 16 +++------ tests/web/test_max_rows_enforcer.py | 3 +- 10 files changed, 109 insertions(+), 82 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index cf4a1b4a5f4..12acf1f870d 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -226,6 +226,51 @@ "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." } } } 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 6676c5a8f62..03056a91915 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,8 @@ 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 import get_int_config +from snuba.state.sentry_options import get_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 +85,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 @@ -317,12 +317,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/web/bulk_delete_query.py b/snuba/web/bulk_delete_query.py index 336bd65ccf1..8a0efd982d7 100644 --- a/snuba/web/bulk_delete_query.py +++ b/snuba/web/bulk_delete_query.py @@ -25,7 +25,8 @@ 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 import get_str_config +from snuba.state.sentry_options import get_bool_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 +229,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 {} 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/tests/lw_deletions/test_lw_deletions.py b/tests/lw_deletions/test_lw_deletions.py index 8bb2ff33fd3..670b166af32 100644 --- a/tests/lw_deletions/test_lw_deletions.py +++ b/tests/lw_deletions/test_lw_deletions.py @@ -8,6 +8,7 @@ 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 @@ -518,6 +519,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 +530,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 +558,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 +568,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 +596,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 +605,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/test_search_issues_api.py b/tests/test_search_issues_api.py index 6c76cd8338b..02ea63fb835 100644 --- a/tests/test_search_issues_api.py +++ b/tests/test_search_issues_api.py @@ -10,7 +10,6 @@ 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 @@ -136,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/web/test_bulk_delete_query.py b/tests/web/test_bulk_delete_query.py index 2fa54bc7de8..22930638cca 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 @@ -97,12 +98,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) @@ -264,10 +265,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 +288,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 +309,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 +331,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_max_rows_enforcer.py b/tests/web/test_max_rows_enforcer.py index 1b9781c4730..8e1df3c283a 100644 --- a/tests/web/test_max_rows_enforcer.py +++ b/tests/web/test_max_rows_enforcer.py @@ -15,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 @@ -91,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) From 453e82e9397fa836453c9b2f23a8d6649cad1ed8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:36:46 +0000 Subject: [PATCH 12/32] ref(options): migrate routing/scheduler/validation flags to sentry-options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates six more read-only keys and converts their test toggles to override_options (decorators, with-blocks for parametrized values, and a context helper): - ignore_clickhouse_settings_override (clickhouse_settings_override.py) - enable_long_term_retention_downsampling (routing_strategies/outcomes_based.py) - storage_routing_config_override, default_storage_routing_config (routing_strategy_selector.py) — JSON-blob configs kept as string options - subscription_primary_task_builder (subscriptions/scheduler.py) — stored as the TaskBuilderMode value string, schema default "jittered" - consistent_override (request/validation.py) — the None/str tri-state becomes a string option where empty means "no override" Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 30 +++++++++++++++++++ .../physical/clickhouse_settings_override.py | 4 +-- snuba/request/validation.py | 5 ++-- snuba/subscriptions/scheduler.py | 7 +++-- .../routing_strategies/outcomes_based.py | 3 +- .../routing_strategy_selector.py | 6 ++-- .../test_clickhouse_settings_override.py | 10 +++---- .../subscriptions/test_builder_mode_state.py | 17 ++++++----- tests/subscriptions/test_scheduler.py | 8 ++--- tests/subscriptions/test_task_builder.py | 22 +++++++------- tests/test_api.py | 9 ++++-- .../routing_strategies/test_outcomes_based.py | 16 +++------- 12 files changed, 84 insertions(+), 53 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 12acf1f870d..80ba9efde37 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -271,6 +271,36 @@ "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." } } } 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/request/validation.py b/snuba/request/validation.py index 43ea9643f16..3379391ad57 100644 --- a/snuba/request/validation.py +++ b/snuba/request/validation.py @@ -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_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("=") 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/web/rpc/storage_routing/routing_strategies/outcomes_based.py b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py index fe22fd1e095..bc3320d764c 100644 --- a/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py +++ b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py @@ -25,6 +25,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 from snuba.web.query import run_query from snuba.web.rpc.common.common import ( timestamp_in_range_condition, @@ -245,7 +246,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_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/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/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_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 19194a49feb..f8240ca07d2 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -1470,9 +1470,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}) + @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") query_data = { "project": 2, "tenant_ids": {"referrer": "test_query", "organization_id": 1234}, 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..d2b8be382e0 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 @@ -95,15 +96,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 +128,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 +160,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) From a3f41e27697f03b684d66866e691bcc99ec5b420 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:52:20 +0000 Subject: [PATCH 13/32] ref(options): migrate replacements subsystem flags to sentry-options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Migrates the remaining replacement read-only knobs and converts their test toggles to override_options (decorators for per-test values, with-blocks where a value flips mid-test or has a pre-read): - skip_final_subscriptions_projects, post_replacement_consistency_projects_denylist, max_group_ids_exclude (query/processors/physical/replaced_groups.py) - max_group_ids_exclude (replacers/projects_query_flags.py) — same key, both sites - skip_seen_offsets, consumer_groups_to_reset_offset_check (replacer.py) - write_node_replacements_global, replacements_bypass_projects (replacers/errors_replacer.py) settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE (256) is a constant so the schema default matches. Bracketed-list string configs keep their "[]" string form. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 35 +++++++++ .../processors/physical/replaced_groups.py | 16 ++-- snuba/replacer.py | 6 +- snuba/replacers/errors_replacer.py | 7 +- snuba/replacers/projects_query_flags.py | 8 +- .../processors/test_replaced_groups.py | 69 ++++++++---------- tests/datasets/test_errors_replacer.py | 73 ++++++++----------- tests/replacer/test_cluster_replacements.py | 27 ++++--- 8 files changed, 129 insertions(+), 112 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 80ba9efde37..4dc82fd999a 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -301,6 +301,41 @@ "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." } } } 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/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/tests/datasets/storages/processors/test_replaced_groups.py b/tests/datasets/storages/processors/test_replaced_groups.py index fda74dc59bd..d601b87544a 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).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).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).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).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,15 +225,16 @@ 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).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, @@ -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,6 +270,7 @@ 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 @@ -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,6 +295,7 @@ 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 @@ -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: @@ -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: @@ -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: @@ -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: @@ -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: @@ -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,6 +461,7 @@ 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. @@ -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,6 +486,7 @@ 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. @@ -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..00db1030ed3 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 @@ -54,9 +54,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) @@ -111,9 +111,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}, @@ -329,8 +327,8 @@ def test_unmerge_insert(self) -> None: 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 +347,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"), @@ -369,11 +365,11 @@ def test_process_offset_twice(self) -> None: # 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 @@ -421,8 +417,8 @@ def test_multiple_partitions(self) -> None: # 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 +437,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"), @@ -457,16 +451,15 @@ 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}]") - - # 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 @@ -596,9 +589,7 @@ def test_delete_unpromoted_tag_process(self) -> None: assert replacement.get_query_time_flags() == errors_replacer.NeedsFinal() - @pytest.mark.parametrize( - "old_primary_hash", ["e3d704f3542b44a621ebed70dc0efe13", False, None] - ) + @pytest.mark.parametrize("old_primary_hash", ["e3d704f3542b44a621ebed70dc0efe13", False, None]) def test_tombstone_events_process(self, old_primary_hash) -> None: timestamp = datetime.now() message_kwargs = { @@ -617,9 +608,7 @@ def test_tombstone_events_process(self, old_primary_hash) -> None: _, replacement = meta_and_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 = { @@ -759,9 +748,7 @@ def test_merge_process(self) -> None: % 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() @@ -898,15 +885,17 @@ def test_project_bypass(self) -> 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") + 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 replacement is not None + + 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/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 From db76ae66907191104ee7549fcecbab02081e0366 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 01:59:24 +0000 Subject: [PATCH 14/32] ref(options): migrate generic_metrics_use_case_killswitch to sentry-options (rust) Reads the generic-metrics use-case killswitch from sentry-options instead of Redis runtime config, matching the other Rust consumer killswitches. The string is substring-matched against the message use_case_id. should_use_killswitch now takes Option (sentry-options reads yield an Option, no Result wrapper); its unit tests are updated accordingly. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- rust_snuba/src/processors/generic_metrics.rs | 25 +++++++++++--------- sentry-options/schemas/snuba/schema.json | 5 ++++ 2 files changed, 19 insertions(+), 11 deletions(-) diff --git a/rust_snuba/src/processors/generic_metrics.rs b/rust_snuba/src/processors/generic_metrics.rs index 8fcac903d2c..e2d675ea9da 100644 --- a/rust_snuba/src/processors/generic_metrics.rs +++ b/rust_snuba/src/processors/generic_metrics.rs @@ -1,14 +1,14 @@ use adler::Adler32; -use anyhow::{anyhow, Context, Error}; +use anyhow::{anyhow, Context}; use chrono::DateTime; use serde::{ de::value::{MapAccessDeserializer, SeqAccessDeserializer}, Deserialize, Deserializer, Serialize, }; +use sentry_options::options; 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/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 4dc82fd999a..190c6251d58 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -336,6 +336,11 @@ "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." } } } From 7659d7a5658901fd54e684278e9a7b0f14a0e189 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 02:02:01 +0000 Subject: [PATCH 15/32] ref(options): use get_bool_option for enable_any_attribute_filter Switches the enable_any_attribute_filter read from the raw get_option to the typed get_bool_option, matching every other boolean key in the migration. The schema already enforces a boolean so behavior is unchanged, but this keeps the call sites consistent and adds the same defensive coercion as the rest. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- snuba/web/rpc/common/common.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/snuba/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index 2cf2161301a..2dd66c97e7e 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -47,7 +47,7 @@ Lambda, SubscriptableReference, ) -from snuba.state.sentry_options import get_int_option, get_option +from snuba.state.sentry_options import get_bool_option, get_int_option from snuba.web.rpc.common.exceptions import BadSnubaRPCRequestException @@ -1076,7 +1076,7 @@ def trace_item_filters_to_expression( ) if item_filter.HasField("any_attribute_filter"): - if not get_option("enable_any_attribute_filter", True): + 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 From 9d8db3c466750009a982e225711428b24962735c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 24 Jun 2026 23:36:24 +0000 Subject: [PATCH 16/32] style(rust): fix sentry_options import ordering for rustfmt Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- rust_snuba/src/processors/generic_metrics.rs | 2 +- rust_snuba/src/processors/utils.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/rust_snuba/src/processors/generic_metrics.rs b/rust_snuba/src/processors/generic_metrics.rs index e2d675ea9da..8f4de883cab 100644 --- a/rust_snuba/src/processors/generic_metrics.rs +++ b/rust_snuba/src/processors/generic_metrics.rs @@ -1,11 +1,11 @@ use adler::Adler32; use anyhow::{anyhow, Context}; use chrono::DateTime; +use sentry_options::options; use serde::{ de::value::{MapAccessDeserializer, SeqAccessDeserializer}, Deserialize, Deserializer, Serialize, }; -use sentry_options::options; use std::{collections::BTreeMap, marker::PhantomData, vec}; use crate::{ diff --git a/rust_snuba/src/processors/utils.rs b/rust_snuba/src/processors/utils.rs index 0a2545ecfad..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::types::item_type_name; -use sentry_options::options; 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}; From 6d6e5aa7ae2015c095f5c1caeb9ff5a431f2235f Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:03:02 +0000 Subject: [PATCH 17/32] ref(options): support dynamic option names via dict-typed options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Some runtime-config keys were named dynamically — one Redis key per storage, topic, dataset, or bucket (f"{prefix}_{name}") — which a static sentry-options schema cannot enumerate. Collapse each family into a single object option (a dict declared with additionalProperties, defaulting to {}) keyed by the dynamic name, and read one entry via new get_mapped_{int,float,str}_option helpers. Migrates the five remaining dynamic-name keys: - lw_deletes_killswitch_ -> lw_deletes_killswitch (dict[str,str]) - lw_deletes_split_by_partition_ -> lw_deletes_split_by_partition (dict[str,int]) - validate_schema_ -> validate_schema_sample_rate (dict[str,number]) - _ignore_consistent_queries_..rate -> ignore_consistent_queries_sample_rate (dict[str,number]) - mem_rate_limit_per_sec_ -> mem_rate_limit_per_sec (dict[str,number]) An absent entry falls back to the call-site default, preserving the previous per-key default. Test toggles converted from set_config to override_options with the dict value. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 30 +++++++++ snuba/consumers/consumer.py | 5 +- snuba/lw_deletions/strategy.py | 8 ++- snuba/state/sentry_options.py | 85 +++++++++++++++++++----- snuba/utils/rate_limiter.py | 9 ++- snuba/web/bulk_delete_query.py | 5 +- snuba/web/db_query.py | 9 +-- tests/lw_deletions/test_lw_deletions.py | 13 ++-- tests/state/test_sentry_options.py | 54 +++++++++++++++ tests/web/test_bulk_delete_query.py | 3 +- tests/web/test_db_query.py | 2 +- 11 files changed, 180 insertions(+), 43 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 190c6251d58..c74d6def25d 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -341,6 +341,36 @@ "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_." } } } diff --git a/snuba/consumers/consumer.py b/snuba/consumers/consumer.py index 875efc5128f..45a3abce6dd 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 @@ -484,7 +485,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/lw_deletions/strategy.py b/snuba/lw_deletions/strategy.py index 03056a91915..1a9813b9820 100644 --- a/snuba/lw_deletions/strategy.py +++ b/snuba/lw_deletions/strategy.py @@ -35,7 +35,11 @@ from snuba.query.query_settings import HTTPQuerySettings from snuba.redis import RedisClientKey, get_redis_client from snuba.state import get_int_config -from snuba.state.sentry_options import get_int_option, get_str_option +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 @@ -206,7 +210,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: diff --git a/snuba/state/sentry_options.py b/snuba/state/sentry_options.py index 8e9e35494b6..f54f58a3ec8 100644 --- a/snuba/state/sentry_options.py +++ b/snuba/state/sentry_options.py @@ -76,14 +76,7 @@ def get_option(key: str, default: OptionValue) -> OptionValue: 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 below only guards against a misconfigured - value and otherwise falls back to ``default``. - """ - value = get_option(key, default) +def _coerce_bool(value: OptionValue, default: bool) -> bool: if isinstance(value, bool): return value if isinstance(value, (int, float)): @@ -93,9 +86,7 @@ def get_bool_option(key: str, default: bool) -> bool: return default -def get_int_option(key: str, default: int) -> int: - """Read ``key`` as an int. Counterpart to ``state.get_int_config``.""" - value = get_option(key, default) +def _coerce_int(value: OptionValue, default: int) -> int: if isinstance(value, bool): return int(value) if isinstance(value, (int, float, str)): @@ -106,9 +97,7 @@ def get_int_option(key: str, default: int) -> int: return default -def get_float_option(key: str, default: float) -> float: - """Read ``key`` as a float. Counterpart to ``state.get_float_config``.""" - value = get_option(key, default) +def _coerce_float(value: OptionValue, default: float) -> float: if isinstance(value, bool): return float(value) if isinstance(value, (int, float, str)): @@ -119,9 +108,71 @@ def get_float_option(key: str, default: float) -> float: return default -def get_str_option(key: str, default: str) -> str: - """Read ``key`` as a str. Counterpart to ``state.get_str_config``.""" - value = get_option(key, 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_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/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 8a0efd982d7..6cec34e0090 100644 --- a/snuba/web/bulk_delete_query.py +++ b/snuba/web/bulk_delete_query.py @@ -25,8 +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_str_config -from snuba.state.sentry_options import get_bool_option +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 @@ -374,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 a365ebd6709..8c378d8d9a7 100644 --- a/snuba/web/db_query.py +++ b/snuba/web/db_query.py @@ -58,7 +58,7 @@ ) from snuba.state.quota import ResourceQuota from snuba.state.rate_limit import RateLimitExceeded -from snuba.state.sentry_options import get_bool_option +from snuba.state.sentry_options import get_bool_option, get_mapped_float_option from snuba.util import force_bytes from snuba.utils.codecs import ExceptionAwareCodec from snuba.utils.metrics.timer import Timer @@ -429,9 +429,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/tests/lw_deletions/test_lw_deletions.py b/tests/lw_deletions/test_lw_deletions.py index 670b166af32..b3bc9f81e98 100644 --- a/tests/lw_deletions/test_lw_deletions.py +++ b/tests/lw_deletions/test_lw_deletions.py @@ -230,6 +230,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, @@ -239,8 +240,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 ( @@ -287,9 +286,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, @@ -307,6 +304,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. @@ -317,8 +315,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) @@ -397,6 +393,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, @@ -406,8 +403,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 ( diff --git a/tests/state/test_sentry_options.py b/tests/state/test_sentry_options.py index e7d4ffeeb77..5de12f45a14 100644 --- a/tests/state/test_sentry_options.py +++ b/tests/state/test_sentry_options.py @@ -7,6 +7,10 @@ get_bool_option, get_float_option, get_int_option, + get_mapped_float_option, + get_mapped_int_option, + get_mapped_option, + get_mapped_str_option, get_option, get_str_option, ) @@ -76,3 +80,53 @@ def test_unexpected_error_falls_back_to_default() -> None: 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_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/web/test_bulk_delete_query.py b/tests/web/test_bulk_delete_query.py index 22930638cca..835beb4fe07 100644 --- a/tests/web/test_bulk_delete_query.py +++ b/tests/web/test_bulk_delete_query.py @@ -17,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 @@ -111,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() diff --git a/tests/web/test_db_query.py b/tests/web/test_db_query.py index fa92576fc0a..4404d0a10f7 100644 --- a/tests/web/test_db_query.py +++ b/tests/web/test_db_query.py @@ -996,9 +996,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] = {} From e49a09870c6e6d9dae39f67e064589c4b104c07c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:15:46 +0000 Subject: [PATCH 18/32] fix(types): resolve pre-existing mypy errors in files touched by the options migration This migration touches files whose latent type errors were never surfaced because mypy.ini excludes tests/datasets/ and tests/query/, while pre-commit passes changed files to mypy explicitly (which bypasses that exclude). Fix the errors properly rather than masking them: - test_errors_replacer.py: narrow process_message() results (assert non-None), narrow Replacement to the errors_replacer subclass that defines get_query_time_flags/get_project_id, route re.sub through a helper that asserts the query string is non-None, annotate args/parametrize. - test_transaction_processor.py: correct serialize()/build_result() return types to the concrete dicts they return; isinstance-narrow processed messages. - test_replaced_groups.py: pass ReplacerState..value (the str the constructor expects) instead of the enum member. - test_db_query.py: narrow excinfo.value to QueryException before .extra/__cause__. - test_uniq_in_select_and_having.py: alias param is Optional[str]. - consumer.py / query_execution.py: type-only casts for confluent_kafka produce() args and a QueryExtraData TypedDict field. All changes are type-only / behavior-preserving. mypy is clean on the full changed set and ruff passes. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- snuba/consumers/consumer.py | 9 ++- snuba/pipeline/stages/query_execution.py | 4 +- .../processors/test_replaced_groups.py | 30 ++++---- tests/datasets/test_errors_replacer.py | 76 +++++++++++-------- tests/datasets/test_transaction_processor.py | 12 +-- .../test_uniq_in_select_and_having.py | 3 +- tests/web/test_db_query.py | 20 +++-- 7 files changed, 91 insertions(+), 63 deletions(-) diff --git a/snuba/consumers/consumer.py b/snuba/consumers/consumer.py index 45a3abce6dd..93524cae660 100644 --- a/snuba/consumers/consumer.py +++ b/snuba/consumers/consumer.py @@ -225,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() @@ -316,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) @@ -442,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) diff --git a/snuba/pipeline/stages/query_execution.py b/snuba/pipeline/stages/query_execution.py index c4c73da193e..460b3ee67fb 100644 --- a/snuba/pipeline/stages/query_execution.py +++ b/snuba/pipeline/stages/query_execution.py @@ -5,7 +5,7 @@ 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 @@ -248,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/tests/datasets/storages/processors/test_replaced_groups.py b/tests/datasets/storages/processors/test_replaced_groups.py index d601b87544a..a0f29d0a344 100644 --- a/tests/datasets/storages/processors/test_replaced_groups.py +++ b/tests/datasets/storages/processors/test_replaced_groups.py @@ -139,7 +139,7 @@ def test_without_turbo_with_projects_needing_final(query: ClickhouseQuery) -> No ) query_settings = HTTPQuerySettings() - PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS).process_query( + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( query, query_settings ) @@ -166,14 +166,14 @@ 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( + 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 with override_options("snuba", {"skip_final_subscriptions_projects": "[2,3,4]"}): - PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS).process_query( + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( query, SubscriptionQuerySettings() ) assert not query.get_from_clause().final @@ -189,7 +189,7 @@ 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( + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( query, HTTPQuerySettings() ) @@ -225,7 +225,7 @@ 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( + PostReplacementConsistencyEnforcer("project_id", ReplacerState.ERRORS.value).process_query( query, HTTPQuerySettings() ) @@ -240,7 +240,7 @@ def test_query_overlaps_replacements_processor( 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) @@ -276,7 +276,7 @@ def test_single_no_replacements(query_with_single_group_id: ClickhouseQuery) -> 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, @@ -301,7 +301,7 @@ def test_single_too_many_exclude(query_with_single_group_id: ClickhouseQuery) -> 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, @@ -329,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, @@ -357,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, @@ -384,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, @@ -412,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, @@ -441,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, @@ -466,7 +466,7 @@ 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, @@ -491,7 +491,7 @@ 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, diff --git a/tests/datasets/test_errors_replacer.py b/tests/datasets/test_errors_replacer.py index 00db1030ed3..9c7413ce365 100644 --- a/tests/datasets/test_errors_replacer.py +++ b/tests/datasets/test_errors_replacer.py @@ -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]]: @@ -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"]], @@ -158,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) == [] @@ -202,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. @@ -236,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 @@ -281,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}] @@ -323,6 +332,7 @@ 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}] @@ -360,6 +370,7 @@ 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 @@ -413,6 +424,7 @@ 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 @@ -449,7 +461,9 @@ def test_reset_consumer_group_offset_check(self) -> None: ) ) - self.replacer.flush_batch([self.replacer.process_message(message)]) + processed = self.replacer.process_message(message) + assert processed is not None + self.replacer.flush_batch([processed]) 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 @@ -527,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", @@ -539,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 ) @@ -565,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", @@ -577,12 +591,12 @@ 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 ) @@ -590,7 +604,7 @@ def test_delete_unpromoted_tag_process(self) -> None: 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: + def test_tombstone_events_process(self, old_primary_hash: Union[str, bool, None]) -> None: timestamp = datetime.now() message_kwargs = { "project_id": self.project_id, @@ -606,6 +620,7 @@ 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 "" @@ -620,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 ) @@ -648,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'", @@ -659,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 ) @@ -687,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'", @@ -698,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 ) @@ -726,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", @@ -738,12 +753,12 @@ 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 ) @@ -767,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", @@ -779,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 ) @@ -808,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'", @@ -819,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 ) @@ -845,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, @@ -855,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 ) @@ -883,7 +899,7 @@ 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 + assert isinstance(replacement, errors_replacer.Replacement) with override_options( "snuba", {"replacements_bypass_projects": f"[{self.project_id + 1}]"} @@ -891,7 +907,7 @@ 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 + assert isinstance(replacement, errors_replacer.Replacement) with override_options( "snuba", diff --git a/tests/datasets/test_transaction_processor.py b/tests/datasets/test_transaction_processor.py index c016ae05261..c0e0fc01cb7 100644 --- a/tests/datasets/test_transaction_processor.py +++ b/tests/datasets/test_transaction_processor.py @@ -2,7 +2,7 @@ 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 @@ -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,13 +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)]) - ret = { + ret: Dict[str, Any] = { "deleted": 0, "project_id": 1, "event_id": str(uuid.UUID(self.event_id)), @@ -426,6 +426,7 @@ def test_missing_transaction_source(self) -> None: 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 @@ -433,6 +434,7 @@ def test_missing_transaction_source(self) -> None: 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: 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 ac70e8839f4..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,4 +1,5 @@ from copy import deepcopy +from typing import Optional import pytest from sentry_options.testing import override_options @@ -13,7 +14,7 @@ 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", diff --git a/tests/web/test_db_query.py b/tests/web/test_db_query.py index 4404d0a10f7..1779bc14135 100644 --- a/tests/web/test_db_query.py +++ b/tests/web/test_db_query.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import Any, Mapping, MutableMapping, Optional +from typing import Any, Mapping, MutableMapping, Optional, cast from unittest import mock import pytest @@ -466,8 +466,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): @@ -702,14 +704,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, ( @@ -908,7 +912,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, @@ -944,7 +950,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 From 81a5ede04e9b2e56c48ff823a7807ce1929e3ca0 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:35:54 +0000 Subject: [PATCH 19/32] fix(tests): force SENTRY_OPTIONS_DIR to in-repo schemas in conftest The test conftest used os.environ.setdefault to point sentry-options at the in-repo schemas, but the Docker test image sets ENV SENTRY_OPTIONS_DIR=/etc/sentry-options (the production values mount, which ships no schemas). setdefault is therefore a no-op in the container, so sentry_options.init() raised SchemaError ("Failed to read file"), init_options() swallowed it, and every override_options-based test failed with NotInitializedError. Force-assign the in-repo path so init() reads the committed schema. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- tests/conftest.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index 2e36359c521..0a6989dab18 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -47,12 +47,15 @@ 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() succeeds regardless - # of the working directory tests are launched from. (Without this it relies - # on the ./sentry-options relative fallback.) - os.environ.setdefault( - "SENTRY_OPTIONS_DIR", - os.path.join(os.path.dirname(__file__), os.pardir, "sentry-options"), + # 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() From 2f39e3d181524dc614c559c4b3b846e12e51b0eb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 00:56:11 +0000 Subject: [PATCH 20/32] fix(tests): override max_group_ids_exclude via sentry-options in replacer test test_query_time_flags_bounded_size patched settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE to bound the excluded-group set, but the production read was migrated to get_int_option("max_group_ids_exclude", settings.REPLACER_MAX_GROUP_IDS_TO_EXCLUDE). Once sentry-options initializes, that returns the schema default (256), ignoring the patched settings fallback, so no bounding occurred and the test saw all 10 group ids instead of the most-recent 5. Override the sentry-option instead. (This surfaced only after the conftest SENTRY_OPTIONS_DIR fix let init() succeed; previously every override_options test died at NotInitializedError first.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- tests/test_replacer.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 From 7763d9a9344b077144b10128a85e33ef1b123ecc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:21:06 +0000 Subject: [PATCH 21/32] ref(options): migrate slicing_mega_cluster_partitions dynamic key to sentry-options This dynamic-name key (slicing_mega_cluster_partitions_) was missed in the first dynamic-options pass because its key is built into a local variable (key = f"{PREFIX}_{storage_set.value}") rather than passed as an f-string literal directly to get_config. Migrate it to a dict option keyed by storage-set name (value is the bracketed logical-partition list), via get_mapped_str_option; also drops a redundant get_config call. Test toggles converted to override_options (which also removes a latent bug in the old delete_config key). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 6 ++++ snuba/datasets/plans/cluster_selector.py | 10 +++--- tests/datasets/plans/test_cluster_selector.py | 33 ++++++++++--------- 3 files changed, 29 insertions(+), 20 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index c74d6def25d..02b9da94584 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -371,6 +371,12 @@ "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_." } } } 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/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 From 6a25d47386e294ca7be92c5904e232c3dc73f979 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:21:06 +0000 Subject: [PATCH 22/32] fix(tests): override storage-routing config via sentry-options in selector tests test_strategy_selector.py set default_storage_routing_config and storage_routing_config_override through runtime state.set_config (via the imported key constants), but RoutingStrategySelector reads them with get_str_option. Once sentry-options initializes, those reads return the schema default ("{}") and ignore the runtime config, so the configured routing was never exercised (test_valid_config_is_parsed_correctly failed in CI; two "expects default" tests passed only by coincidence). Convert all 11 set_config sites to override_options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- .../test_strategy_selector.py | 296 +++++++++--------- 1 file changed, 150 insertions(+), 146 deletions(-) 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), + ] From e43e7d7908824646b8327f44c039c70b22157e17 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 01:25:00 +0000 Subject: [PATCH 23/32] ref(options): migrate MappingOptimizer hashmap killswitches to sentry-options The MappingOptimizer reads a per-storage killswitch whose key name comes from the storage YAML (self.__killswitch). The distinct names are a fixed set of four static keys, so migrate them as boolean options (default true, matching the old get_config(..., 1) "enabled unless explicitly disabled" behavior): - tags_hash_map_enabled - generic_metrics/tags_hash_map_enabled - events_tags_hash_map_enabled - events_flags_hash_map_enabled Switch the read to get_bool_option(self.__killswitch, True); convert the one test toggle to override_options. (Confirmed sentry-options accepts the '/' in the generic_metrics key.) Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 20 +++++++++++++++++++ .../processors/physical/mapping_optimizer.py | 4 ++-- .../processors/test_mapping_optimizer.py | 4 ++-- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 02b9da94584..65fc69b7e2a 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -377,6 +377,26 @@ "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." } } } 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/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", From f94aebff6a54161c8e9dde0d62a929c14aea1bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 02:09:40 +0000 Subject: [PATCH 24/32] ref(options): migrate per-storage Rust consumer configs to sentry-options Migrate the dynamic-by-storage runtime_config reads in the Rust consumers to sentry-options dict options (object, additionalProperties keyed by storage name), read via options("snuba") instead of the Redis runtime-config bridge: - clickhouse_load_balancing (string dict, default "in_order") - clickhouse_load_balancing_first_offset (string dict; read in the same get_load_balancing_config(), migrated together to avoid splitting its reads) - clickhouse_max_insert_block_size (integer dict; <1048449 still ignored) - eap_items_dlq_grace_period_min (integer dict) Test toggles in runtime_config and writer_v2 converted from patch_str_config_for_test to sentry_options::testing::override_options + a once-init of the embedded SNUBA_SCHEMA (matching the blq_router/healthcheck pattern). cargo check/test/fmt all pass. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- rust_snuba/src/processors/eap_items.rs | 8 +- rust_snuba/src/runtime_config.rs | 71 ++++++++++---- .../src/strategies/clickhouse/writer_v2.rs | 98 ++++++++++++------- sentry-options/schemas/snuba/schema.json | 24 +++++ 4 files changed, 140 insertions(+), 61 deletions(-) diff --git a/rust_snuba/src/processors/eap_items.rs b/rust_snuba/src/processors/eap_items.rs index 768a697084f..73f81a4e20e 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) } diff --git a/rust_snuba/src/runtime_config.rs b/rust_snuba/src/runtime_config.rs index d5d7b68472d..d234e7dc989 100644 --- a/rust_snuba/src/runtime_config.rs +++ b/rust_snuba/src/runtime_config.rs @@ -7,6 +7,7 @@ use std::time::Duration; use sentry_arroyo::timer; use sentry_arroyo::utils::timing::Deadline; +use sentry_options::options; static CONFIG: RwLock, Deadline)>> = RwLock::new(BTreeMap::new()); @@ -54,16 +55,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,16 +96,26 @@ 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; + + static INIT: Once = Once::new(); + + fn init_options() { + INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); + } #[test] fn test_runtime_config() { @@ -102,7 +126,7 @@ mod tests { #[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 +134,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/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 65fc69b7e2a..51b53a01f55 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -397,6 +397,30 @@ "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:." } } } From f676c09970bc68dcdca854b3fa4695c79ef84d95 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 02:17:13 +0000 Subject: [PATCH 25/32] ref(options): migrate query_settings to sentry-options dict options _get_query_settings_from_config read ClickHouse query settings from runtime config via get_all_configs() and prefix filtering. Migrate to four sentry-options dicts (object, additionalProperties string): - query_settings / async_query_settings: {setting: value} - query_settings_by_prefix / query_settings_by_referrer: keyed by prefix/referrer, each value a JSON-object string {setting: value} (sentry-options can't express dict-of-dict, so the second level is JSON-in-string), preserving the referrer > prefix > base precedence. Values are string-typed (ClickHouse HTTP settings are strings on the wire). The parametrized test now applies config via override_options (no longer needs redis) and compares against stringified expected values. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 24 +++++++++ snuba/web/db_query.py | 66 ++++++++++++++++-------- tests/web/test_db_query.py | 46 ++++++++++++++--- 3 files changed, 107 insertions(+), 29 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 51b53a01f55..4824f1ed7df 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -421,6 +421,30 @@ "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/." } } } diff --git a/snuba/web/db_query.py b/snuba/web/db_query.py index 8c378d8d9a7..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,7 +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 +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 @@ -349,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 diff --git a/tests/web/test_db_query.py b/tests/web/test_db_query.py index 1779bc14135..bcd264fdf04 100644 --- a/tests/web/test_db_query.py +++ b/tests/web/test_db_query.py @@ -1,12 +1,12 @@ from __future__ import annotations +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 @@ -193,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], @@ -202,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( From e10a0614efc6b9c8ced345db2552351e2d525025 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 02:19:17 +0000 Subject: [PATCH 26/32] test(rust): cover dict-option read in get_dlq_grace_period_min Add a test that overrides the eap_items_dlq_grace_period_min dict option and asserts get_dlq_grace_period_min returns the per-storage value (Some(45)), plus absent-key (None) and negative-value (rejected) cases. This exercises the nested serde_json::Value get on the value returned by options("snuba").get(...), which the other migrated reads (load balancing, max insert block size) already rely on. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- rust_snuba/src/processors/eap_items.rs | 39 +++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/rust_snuba/src/processors/eap_items.rs b/rust_snuba/src/processors/eap_items.rs index 73f81a4e20e..c63c09bb3db 100644 --- a/rust_snuba/src/processors/eap_items.rs +++ b/rust_snuba/src/processors/eap_items.rs @@ -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(); From 351352d939018439cf735e9d4f7f1e71808db924 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 03:33:46 +0000 Subject: [PATCH 27/32] ref(options): migrate remaining read-only runtime configs to sentry-options A full audit of `state.get_config`/`get_configs` read sites surfaced seven read-only runtime configs that were still backed by Redis. Migrate each to the `snuba` sentry-options namespace: - optimize_parallel_threads (clickhouse/optimize/util.py) - http_batch_join_timeout (clickhouse/http.py) - simultaneous_queries_sleep_seconds (clickhouse/native.py, two sites) - max_days / date_align_seconds (query/snql/parser.py) - snql_disabled_dataset__ -> snql_disabled_dataset dict (request/validation.py) - quantized_rebalance_consumer_group_delay_secs__ -> dict (rust_snuba rebalancing) - bypass_rate_limit / rate_history_sec / rate_limit_shard_factor (state/rate_limit.py) The two configs whose fallback came from a caller arg / env setting (optimize_parallel_threads, http_batch_join_timeout) use a sentinel-0 schema default and fall back to the original value, preserving the prior "option is only an override" behavior. The two per-suffix keys collapse into single dict options keyed by the dynamic part, matching the pattern used for the earlier dynamic-name migrations; a new get_mapped_bool_option helper backs the boolean dict. Migrating the rebalancing consumer was the last Rust caller of the Python-bridge runtime_config::get_str_config, so that reader (plus its cache and test-patch helper) is removed. Tests that previously set these via state.set_config now use sentry_options.testing.override_options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- rust_snuba/src/rebalancing.rs | 65 ++++++++++--------- rust_snuba/src/runtime_config.rs | 56 ---------------- sentry-options/schemas/snuba/schema.json | 52 +++++++++++++++ snuba/clickhouse/http.py | 9 ++- snuba/clickhouse/native.py | 15 +++-- snuba/clickhouse/optimize/util.py | 8 ++- snuba/query/snql/parser.py | 11 ++-- snuba/request/validation.py | 9 +-- snuba/state/rate_limit.py | 27 +++----- snuba/state/sentry_options.py | 6 ++ tests/clickhouse/test_native.py | 4 +- .../snql/test_query_column_validation.py | 44 ++++--------- tests/request/test_build_request.py | 6 +- tests/state/test_rate_limit.py | 15 +++-- tests/state/test_sentry_options.py | 14 ++++ tests/test_api.py | 39 ++++++----- tests/test_metrics_api.py | 1 - tests/test_transactions_api.py | 1 - 18 files changed, 190 insertions(+), 192 deletions(-) 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 d234e7dc989..4344a877dba 100644 --- a/rust_snuba/src/runtime_config.rs +++ b/rust_snuba/src/runtime_config.rs @@ -1,54 +1,5 @@ -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; use sentry_options::options; -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 -} - pub struct LoadBalancingConfig { pub load_balancing: String, pub first_offset: Option, @@ -117,13 +68,6 @@ mod tests { INIT.call_once(|| init_with_schemas(&[("snuba", crate::SNUBA_SCHEMA)]).unwrap()); } - #[test] - fn test_runtime_config() { - crate::testutils::initialize_python(); - let config = get_str_config("test"); - assert_eq!(config.unwrap(), None); - } - #[test] fn test_load_balancing_config_defaults() { init_options(); diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 4824f1ed7df..2046975d084 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -445,6 +445,58 @@ "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." } } } diff --git a/snuba/clickhouse/http.py b/snuba/clickhouse/http.py index fc5318af1fd..3aa79f32127 100644 --- a/snuba/clickhouse/http.py +++ b/snuba/clickhouse/http.py @@ -23,7 +23,7 @@ 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 @@ -348,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 a737a5e30d4..1a24116907d 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/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/request/validation.py b/snuba/request/validation.py index 3379391ad57..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,7 +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_str_option +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 @@ -112,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/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 index f54f58a3ec8..f16f1ad6aec 100644 --- a/snuba/state/sentry_options.py +++ b/snuba/state/sentry_options.py @@ -160,6 +160,12 @@ def get_mapped_option(key: str, name: str, default: OptionValue) -> OptionValue: 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`).""" 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/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/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_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 index 5de12f45a14..a98b5b1fd29 100644 --- a/tests/state/test_sentry_options.py +++ b/tests/state/test_sentry_options.py @@ -7,6 +7,7 @@ get_bool_option, get_float_option, get_int_option, + get_mapped_bool_option, get_mapped_float_option, get_mapped_int_option, get_mapped_option, @@ -110,6 +111,19 @@ def test_mapped_option_falls_back_when_option_unset() -> None: 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. diff --git a/tests/test_api.py b/tests/test_api.py index f8240ca07d2..8e1e368b53e 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -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 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_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: """ From 43b0cc1f563b8c4098ec42551287c9fe4ebcbf0b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 22:22:44 +0000 Subject: [PATCH 28/32] fix(options): migrate lightweight_delete_mode/lightweight_deletes_sync to sentry-options The master merge combined #8106's new get_str_config("lightweight_delete_mode") read with our branch's import block (which no longer imported get_str_config), producing an undefined-name failure in pre-commit. Migrate both lightweight-delete ClickHouse-setting reads in lw_deletions/strategy.py to sentry-options instead of re-adding the legacy import: - lightweight_deletes_sync -> integer option, schema default -1 ("unset", leave ClickHouse's own default in place) - lightweight_delete_mode -> string option, schema default "" (unset) test_clickhouse_settings now drives the two flushes via override_options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 10 ++++++++++ snuba/lw_deletions/strategy.py | 10 +++++----- tests/lw_deletions/test_lw_deletions.py | 17 ++++++++--------- 3 files changed, 23 insertions(+), 14 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index 2046975d084..d0e6a573c65 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -497,6 +497,16 @@ "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." } } } diff --git a/snuba/lw_deletions/strategy.py b/snuba/lw_deletions/strategy.py index 68e77a91b23..51c36232160 100644 --- a/snuba/lw_deletions/strategy.py +++ b/snuba/lw_deletions/strategy.py @@ -34,7 +34,6 @@ 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 from snuba.state.sentry_options import ( get_int_option, get_mapped_int_option, @@ -203,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 diff --git a/tests/lw_deletions/test_lw_deletions.py b/tests/lw_deletions/test_lw_deletions.py index b3bc9f81e98..a0633c6ae14 100644 --- a/tests/lw_deletions/test_lw_deletions.py +++ b/tests/lw_deletions/test_lw_deletions.py @@ -10,7 +10,6 @@ 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 @@ -99,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 From 822c23ee14fdbee9a803ded29756f791036fcf4b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 22:26:06 +0000 Subject: [PATCH 29/32] ref(options): migrate replacements_expiry_window_minutes to sentry-options Standalone read-only runtime config in the replacer's auto-replacements bypass-expiry path; not part of the ConfigurableComponent system. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 5 +++++ snuba/replacers/replacements_and_expiry.py | 7 ++----- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index d0e6a573c65..c470b682224 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -507,6 +507,11 @@ "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." } } } 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: From bae0bc105dde2119de9beee76bb7a89411707336 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 23:34:04 +0000 Subject: [PATCH 30/32] ref(options): migrate ConfigurableComponent + routing-strategy configs to sentry-options Migrate the remaining runtime-config reads in the allocation-policy / storage- routing-strategy (ConfigurableComponent) subsystem to sentry-options: - ConfigurableComponent.get_config_value now consults a single sentry-options dict, `configurable_component_overrides`, keyed by the same fully-qualified 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. This is a single chokepoint, so it covers every allocation policy and routing strategy at once, and it is the authoritative source: a key absent from the option falls back to the legacy Redis runtime config and then the code default. With the option defaulting to {}, behavior is unchanged until the automator is populated. - The storage-routing strategies' direct state.get_int_config reads (time_budget_ms, sampled_too_low_threshold, max_items_before_downsampling, min_timerange_to_query_outcomes) now read per-strategy dict options keyed by class name, preserving the per-strategy -> global ("StorageRouting") -> default fallback chain. The legacy set_config_value / admin write path is left intact as the transitional fallback; editing now happens centrally via the sentry-options-automator. Tests that set these via state.set_config now use override_options; added coverage for the new override precedence (incl. coercion and parameterized keys). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 30 ++++ snuba/configs/configuration.py | 42 +++++- .../routing_strategies/outcomes_based.py | 13 +- .../routing_strategies/storage_routing.py | 31 ++-- tests/test_configurable_component.py | 43 ++++++ .../routing_strategies/test_outcomes_based.py | 132 ++++++++++-------- tests/web/rpc/v1/test_storage_routing.py | 32 +++-- 7 files changed, 236 insertions(+), 87 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index c470b682224..bf5d65dbc21 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -512,6 +512,36 @@ "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." } } } 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/web/rpc/storage_routing/routing_strategies/outcomes_based.py b/snuba/web/rpc/storage_routing/routing_strategies/outcomes_based.py index bc3320d764c..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,7 +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 +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, @@ -216,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 @@ -226,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 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 635ba02db79..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,7 +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 +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 @@ -619,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, @@ -639,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/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/web/rpc/v1/routing_strategies/test_outcomes_based.py b/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py index d2b8be382e0..7ef94d6f5f2 100644 --- a/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py +++ b/tests/web/rpc/v1/routing_strategies/test_outcomes_based.py @@ -17,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 @@ -287,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", @@ -343,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 @@ -397,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/test_storage_routing.py b/tests/web/rpc/v1/test_storage_routing.py index 7ed9bf52498..fd98f78f229 100644 --- a/tests/web/rpc/v1/test_storage_routing.py +++ b/tests/web/rpc/v1/test_storage_routing.py @@ -12,7 +12,6 @@ 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 @@ -401,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 @@ -419,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, @@ -431,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"][ @@ -449,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, @@ -461,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"][ From edfdee39801116d08048713e6bdc287e7f37f100 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Jun 2026 23:47:53 +0000 Subject: [PATCH 31/32] fix(options): migrate use_array_map_columns_timestamp_seconds after master merge Master #8101 added use_array_map_columns() in web/rpc/common/common.py reading state.get_int_config("use_array_map_columns_timestamp_seconds", ...) with a typing.cast. This branch had already removed the state/cast imports from that file when migrating the neighbouring use_sampling_factor read, so the merge produced undefined-name failures (a semantic conflict with no textual clash). Migrate the new read to get_int_option (mirroring use_sampling_factor), which also removes the need for the dropped imports. Add the use_array_map_columns_ timestamp_seconds integer option (default 1782172800) and convert its new test from snuba_set_config to override_options. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- sentry-options/schemas/snuba/schema.json | 5 +++++ snuba/web/rpc/common/common.py | 9 +++------ tests/web/rpc/test_common.py | 10 +++++----- 3 files changed, 13 insertions(+), 11 deletions(-) diff --git a/sentry-options/schemas/snuba/schema.json b/sentry-options/schemas/snuba/schema.json index bf5d65dbc21..03c7ffa14f1 100644 --- a/sentry-options/schemas/snuba/schema.json +++ b/sentry-options/schemas/snuba/schema.json @@ -542,6 +542,11 @@ "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/web/rpc/common/common.py b/snuba/web/rpc/common/common.py index c29a317f997..e554d7f2a48 100644 --- a/snuba/web/rpc/common/common.py +++ b/snuba/web/rpc/common/common.py @@ -263,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 diff --git a/tests/web/rpc/test_common.py b/tests/web/rpc/test_common.py index 0eefd526701..fba85b6fdfc 100644 --- a/tests/web/rpc/test_common.py +++ b/tests/web/rpc/test_common.py @@ -116,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: From 833e6a0e2876336252d718ce5c6ae3af62ff2992 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 00:04:21 +0000 Subject: [PATCH 32/32] fix(tests): patch get_int_option in replacements-expiry test after migration test_expiry_window_changes mock.patched snuba.replacers.replacements_and_expiry.get_int_config, but that read was migrated to get_int_option, so the patch target no longer existed (AttributeError at collection of the patched test). Patch get_int_option instead (preserving the side_effect=[5, 10] per-call semantics) and read the class-level baseline via get_int_option too. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01U2Cu68uGZRcCVS14jcyd3E --- tests/replacer/test_replacements_and_expiry.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) 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]