Skip to content
Merged
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -438,7 +438,10 @@ jobs:
uses: ./.github/actions/setup-sentry
with:
workdir: sentry
mode: minimal
# backend-ci includes objectstore + symbolicator so full sentry
# tests marked @requires_objectstore (e.g. minidump) can run.
# minimal is only postgres+snuba and fails those tests.
mode: backend-ci

- name: Start snuba
run: |
Expand Down
52 changes: 45 additions & 7 deletions snuba/datasets/entities/storage_selectors/outcomes.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,44 @@
from collections.abc import Sequence
from datetime import UTC, datetime, timedelta

from snuba.clickhouse.query_dsl.accessors import get_time_range
from snuba.datasets.entities.storage_selectors import QueryStorageSelector
from snuba.datasets.entities.storage_selectors.selector import QueryStorageSelectorError
from snuba.datasets.storage import EntityStorageConnection, ReadableTableStorage
from snuba.datasets.storages.storage_key import StorageKey
from snuba.query.logical import Query
from snuba.query.query_settings import OutcomesQuerySettings, QuerySettings

# Matches the hourly table TTL: timestamp + toIntervalDay(90).
HOURLY_RETENTION_DAYS = 90
HOURLY_RETENTION = timedelta(days=HOURLY_RETENTION_DAYS)


def hourly_retention_cutoff(now: datetime | None = None) -> datetime:
if now is None:
now = datetime.now(UTC)
elif now.tzinfo is None:
now = now.replace(tzinfo=UTC)
return now - HOURLY_RETENTION


class OutcomesStorageSelector(QueryStorageSelector):
"""
Outcomes storage selector to decide whether to query the hourly or daily
outcomes tables
Outcomes storage selector that decides whether to query the hourly or
daily outcomes tables.

Routing priority:
1. OutcomesQuerySettings(use_daily=True) — explicit opt-in to daily.
2. Time-range — if the query's lower timestamp bound is older than
the hourly table's 90-day retention, route to daily.
3. Referrer — if the referrer starts with "billing.", route to daily
(billing queries need 13-month retention only available in the daily
table).
4. Default — hourly.

OutcomesQuerySettings(use_daily=False) is not an opt-out: it falls
through to time-range and referrer routing so old windows still reach
the daily table.
"""

def __init__(self) -> None:
Expand All @@ -24,12 +51,10 @@ def select_storage(
query_settings: QuerySettings,
storage_connections: Sequence[EntityStorageConnection],
) -> EntityStorageConnection:
if isinstance(query_settings, OutcomesQuerySettings):
outcomes_key = (
self.daily_storage if query_settings.get_use_daily() else self.hourly_storage
)
if isinstance(query_settings, OutcomesQuerySettings) and query_settings.get_use_daily():
outcomes_key = self.daily_storage
else:
outcomes_key = self.hourly_storage
outcomes_key = self._select_storage_key(query, query_settings)

for storage_connection in storage_connections:
assert isinstance(storage_connection.storage, ReadableTableStorage)
Expand All @@ -39,3 +64,16 @@ def select_storage(
raise QueryStorageSelectorError(
"The specified storage in selector does not exist in storage list."
)

def _select_storage_key(self, query: Query, query_settings: QuerySettings) -> StorageKey:
lower_bound, _ = get_time_range(query, "timestamp")
if lower_bound is not None:
if lower_bound.tzinfo is None:
lower_bound = lower_bound.replace(tzinfo=UTC)
if lower_bound < hourly_retention_cutoff():
return self.daily_storage

if query_settings.referrer.startswith("billing."):
return self.daily_storage

return self.hourly_storage
Comment thread
phacops marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
from snuba.configs.configuration import Configuration
from snuba.datasets.entities.entity_key import EntityKey
from snuba.datasets.entities.factory import get_entity
from snuba.datasets.entities.storage_selectors.outcomes import (
HOURLY_RETENTION_DAYS,
hourly_retention_cutoff,
)
from snuba.datasets.pluggable_dataset import PluggableDataset
from snuba.downsampled_storage_tiers import Tier
from snuba.query import SelectedExpression
Expand Down Expand Up @@ -115,11 +119,16 @@ def _additional_config_definitions(self) -> list[Configuration]:
]

def _use_daily(self, in_msg_meta: RequestMeta) -> bool:
if in_msg_meta.end_timestamp.seconds < in_msg_meta.start_timestamp.seconds:
"""Route the outcomes estimate to the daily table when needed.

The hourly outcomes table retains 90 days. Use daily when the
requested window is longer than that *or* starts before now - 90d.
"""
start = datetime.fromtimestamp(in_msg_meta.start_timestamp.seconds, tz=UTC)
end = datetime.fromtimestamp(in_msg_meta.end_timestamp.seconds, tz=UTC)
if end < start:
return False
seconds_delta = in_msg_meta.end_timestamp.seconds - in_msg_meta.start_timestamp.seconds
duration = timedelta(seconds=seconds_delta)
return duration.days > 90
return (end - start).days > HOURLY_RETENTION_DAYS or start < hourly_retention_cutoff()

def get_item_types_in_query(
self, routing_context: RoutingContext
Expand Down
194 changes: 176 additions & 18 deletions tests/datasets/entities/storage_selectors/test_outcomes.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
from datetime import UTC, datetime, timedelta
from unittest.mock import patch

import pytest

from snuba.datasets.entities.entity_key import EntityKey
from snuba.datasets.entities.factory import get_entity
from snuba.datasets.entities.storage_selectors.outcomes import OutcomesStorageSelector
from snuba.datasets.entities.storage_selectors.outcomes import (
OutcomesStorageSelector,
hourly_retention_cutoff,
)
from snuba.datasets.storage import Storage
from snuba.datasets.storages.factory import get_storage
from snuba.datasets.storages.storage_key import StorageKey
from snuba.query.conditions import BooleanFunctions, ConditionFunctions, binary_condition
from snuba.query.data_source.simple import Entity
from snuba.query.expressions import Column, FunctionCall, Literal
from snuba.query.logical import Query
from snuba.query.query_settings import HTTPQuerySettings, OutcomesQuerySettings

Expand All @@ -18,30 +26,180 @@
DAILY = get_storage(StorageKey.OUTCOMES_DAILY)
HOURLY = get_storage(StorageKey.OUTCOMES_HOURLY)

TEST_CASES = [
pytest.param(OutcomesQuerySettings(), HOURLY),
pytest.param(OutcomesQuerySettings(use_daily=True), DAILY),
pytest.param(HTTPQuerySettings(), HOURLY),
# Frozen so cutoff / boundary cases do not drift with wall-clock time.
_NOW = datetime(2026, 4, 23, 12, 0, 0, tzinfo=UTC)
_CUTOFF = hourly_retention_cutoff(_NOW)
_OLD_START = _NOW - timedelta(days=120)
_OLD_END = _CUTOFF
_RECENT_START = _NOW - timedelta(days=30)
_RECENT_END = _NOW - timedelta(days=1)
_JUST_BEFORE_CUTOFF = _CUTOFF - timedelta(seconds=1)
_NAIVE_OLD_START = datetime(2025, 12, 24, 12, 0, 0) # 120 days before _NOW, no tz


def _make_timestamp_condition(start: datetime, end: datetime) -> FunctionCall:
"""Build a ``timestamp >= start AND timestamp < end`` condition node."""
return binary_condition(
BooleanFunctions.AND,
binary_condition(
ConditionFunctions.GTE,
Column(None, None, "timestamp"),
Literal(None, start),
),
binary_condition(
ConditionFunctions.LT,
Column(None, None, "timestamp"),
Literal(None, end),
),
)


def _query_with_timestamps(start: datetime, end: datetime) -> Query:
"""Return a Query whose WHERE clause contains a timestamp range."""
return Query(
from_clause=OUTCOMES_ENTITY,
condition=_make_timestamp_condition(start, end),
)


def _select(query: Query, settings: HTTPQuerySettings) -> Storage:
connections = get_entity(EntityKey.OUTCOMES).get_all_storage_connections()
return OutcomesStorageSelector().select_storage(query, settings, connections).storage


# --- Test cases without timestamp conditions (query is irrelevant) ----------

NO_TIMESTAMP_CASES = [
pytest.param(OutcomesQuerySettings(), HOURLY, id="outcomes_settings_default_hourly"),
pytest.param(OutcomesQuerySettings(use_daily=True), DAILY, id="outcomes_settings_use_daily"),
pytest.param(OutcomesQuerySettings(use_daily=False), HOURLY, id="outcomes_settings_no_daily"),
pytest.param(HTTPQuerySettings(), HOURLY, id="no_timestamp_default_hourly"),
pytest.param(
HTTPQuerySettings(referrer="outcomes.timeseries"),
HOURLY,
id="no_timestamp_non_billing_hourly",
),
pytest.param(
HTTPQuerySettings(referrer="billing.usage_service.clickhouse"),
DAILY,
id="no_timestamp_billing_referrer_daily",
),
pytest.param(
HTTPQuerySettings(referrer="billing.anything"),
DAILY,
id="no_timestamp_billing_prefix_daily",
),
]


@pytest.mark.parametrize("settings, expected_storage", NO_TIMESTAMP_CASES)
def test_storage_selector_no_timestamp(
settings: HTTPQuerySettings,
expected_storage: Storage,
) -> None:
"""
Routing without timestamp conditions in the query.

- OutcomesQuerySettings with use_daily=True -> daily.
- Referrers starting with "billing." -> daily (13-month retention).
- Everything else -> hourly.
"""
query = Query(from_clause=OUTCOMES_ENTITY)
assert _select(query, settings) == expected_storage


# --- Test cases with timestamp conditions (hybrid routing) ------------------

TIMESTAMP_CASES = [
# Beyond hourly retention -> daily, regardless of referrer
pytest.param(
_query_with_timestamps(_OLD_START, _OLD_END),
HTTPQuerySettings(referrer="outcomes.timeseries"),
DAILY,
id="old_range_non_billing_daily",
),
pytest.param(
_query_with_timestamps(_OLD_START, _OLD_END),
HTTPQuerySettings(referrer="billing.anything"),
DAILY,
id="old_range_billing_daily",
),
# Inside hourly retention -> referrer fallback
pytest.param(
_query_with_timestamps(_RECENT_START, _RECENT_END),
HTTPQuerySettings(referrer="billing.anything"),
DAILY,
id="recent_range_billing_daily",
),
pytest.param(
_query_with_timestamps(_RECENT_START, _RECENT_END),
HTTPQuerySettings(referrer="outcomes.timeseries"),
HOURLY,
id="recent_range_non_billing_hourly",
),
# Start exactly at now - 90d is still within hourly retention
pytest.param(
_query_with_timestamps(_CUTOFF, _NOW),
HTTPQuerySettings(referrer="outcomes.timeseries"),
HOURLY,
id="cutoff_hourly",
),
# Anything older than now - 90d goes to daily
pytest.param(
_query_with_timestamps(_JUST_BEFORE_CUTOFF, _NOW),
HTTPQuerySettings(referrer="outcomes.timeseries"),
DAILY,
id="before_cutoff_daily",
),
# SNQL datetime literals are naive; still route on time range
pytest.param(
_query_with_timestamps(_NAIVE_OLD_START, _OLD_END.replace(tzinfo=None)),
HTTPQuerySettings(referrer="outcomes.timeseries"),
DAILY,
id="naive_old_range_daily",
),
]


@pytest.mark.parametrize(
"settings, expected_storage",
TEST_CASES,
@pytest.mark.parametrize("query, settings, expected_storage", TIMESTAMP_CASES)
@patch(
"snuba.datasets.entities.storage_selectors.outcomes.datetime",
wraps=datetime,
)
def test_storage_selector(
def test_storage_selector_with_timestamps(
mock_datetime: object,
query: Query,
settings: HTTPQuerySettings,
expected_storage: Storage,
) -> None:
"""
Test that we route queries to either hourly or daily outcomes tables
based on the `use_daily` setting passed through in OutcomesQuerySettings
If just HTTPQuerySettings, then uses hourly table.
Hybrid routing: time-range check takes priority over referrer.

- Query start older than now - 90d -> daily.
- Query start within 90 days + billing referrer -> daily.
- Query start within 90 days + non-billing referrer -> hourly.
"""
unimportant_query = Query(from_clause=OUTCOMES_ENTITY)
connections = get_entity(EntityKey.OUTCOMES).get_all_storage_connections()
mock_datetime.now.return_value = _NOW # type: ignore[attr-defined]
assert _select(query, settings) == expected_storage

selected_storage = OutcomesStorageSelector().select_storage(
unimportant_query, settings, connections
)
assert selected_storage.storage == expected_storage

@patch(
"snuba.datasets.entities.storage_selectors.outcomes.datetime",
wraps=datetime,
)
def test_use_daily_false_falls_through_to_time_range(mock_datetime: object) -> None:
"""use_daily=False is not an opt-out; old windows still go to daily."""
mock_datetime.now.return_value = _NOW # type: ignore[attr-defined]
query = _query_with_timestamps(_OLD_START, _OLD_END)
assert _select(query, OutcomesQuerySettings(use_daily=False)) == DAILY


@patch(
"snuba.datasets.entities.storage_selectors.outcomes.datetime",
wraps=datetime,
)
def test_use_daily_true_wins_over_recent_range(mock_datetime: object) -> None:
"""use_daily=True is an explicit opt-in even for recent windows."""
mock_datetime.now.return_value = _NOW # type: ignore[attr-defined]
query = _query_with_timestamps(_RECENT_START, _RECENT_END)
assert _select(query, OutcomesQuerySettings(use_daily=True)) == DAILY
14 changes: 14 additions & 0 deletions tests/web/rpc/v1/routing_strategies/test_outcomes_based.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,20 @@ def test_outcomes_based_routing_queries_daily_table() -> None:
assert routing_decision.can_run


def test_use_daily_when_window_starts_beyond_hourly_retention() -> None:
"""A short window that starts past hourly TTL still uses the daily table."""
strategy = OutcomesBasedRoutingStrategy()
end = datetime.now(UTC) - timedelta(days=95)
start = end - timedelta(days=7)
in_msg_meta = _get_request_meta(start=start, end=end)
assert strategy._use_daily(in_msg_meta=in_msg_meta)

recent_end = datetime.now(UTC)
recent_start = recent_end - timedelta(days=7)
recent_meta = _get_request_meta(start=recent_start, end=recent_end)
assert not strategy._use_daily(in_msg_meta=recent_meta)


@pytest.mark.eap
@pytest.mark.redis_db
def test_item_type_full_retention() -> None:
Expand Down
Loading