Skip to content

Commit b73b480

Browse files
fix(tests): Fix outstanding flaky tests (#128264)
Fixes the root causes of backend tests that still fail on master, based on the per-attempt failure events in the `sentry-tests` project over the last 30 days. There is one commit per cause. All fixes are test-only except one: the uptime alert index now orders its pagination by `id`. The customer-domain `(?!new)` bug was moved to its own PRs, #128274 (backend) and #128275 (frontend). ### Production change: unordered pagination in the uptime alert index (`test_owner_filter` 11, `test` 2) `OrganizationUptimeAlertIndexEndpoint` paginates with `OffsetPaginator` and no `order_by`, so offset and limit are applied to an unordered queryset. The order within a page, and which rows land on which page, follows Postgres row order. That is a real correctness bug: rows can be duplicated or skipped across pages. It now orders by `id`. `test_paginates_in_id_order` is a new regression test. It creates five detectors, deletes and reinserts their rows newest first so the physical order is the reverse of id order, then walks three pages with `per_page=2` and the cursor. It checks that each page has the expected ids in ascending order, with no gaps or duplicates. Without the fix it gets the rows in reverse. Refs #108890 Refs #108884 ### Test-only fixes **Unordered queries in the deploy notification: `SlackDeployNotificationTest::test_deploy_block` (12).** The buttons come from `release.projects.all()` and the footer from `release.projects.all()[0]`. These are two unordered queries, and the `LIMIT 1` one can use a different join plan, so the footer can name a project other than the first button. The production code does not promise any order between them. The test still checks every button's slug, URL, and value, and now checks that the footer names one of the release's projects instead of the first button's. Refs #108927 **Postgres row order: `test_redirect_for_logged_in_user_with_different_active_org` (19).** The session had no active org, so the channel login view fell back to the first of the user's orgs from an unordered query. When Postgres returned the second org first, the user counted as already logged in to the target org. The test now sets `activeorg` in the session, which is the state its comment describes. Refs #108900 Refs #108881 **Snuba rows left over from an earlier test: feedback categories (17), label query (13 + 3), `EventTest::test_snuba_data` (3).** These classes write to and read from Snuba but never reset it. Snowflake ids are built from the wall-clock second plus a Redis sequence, and Redis is flushed and Postgres rolled back after every test. So two tests that start in the same second get the same org and project ids, and the second one reads the first one's rows. The classes now use the `reset_snuba` fixture through `@pytest.mark.usefixtures`, not `SnubaTestCase`. `SnubaTestCase` also overrides `create_project()` and `store_event()`, which would change the fixtures and ingestion these tests run against, and none of them needs that. Snowflake id reuse across tests is broader than these classes. Any test that reads Snuba data without resetting it can hit it. This PR fixes only the tests that fail. Refs #108654 Refs #109451 Refs #109450 Refs #109445 Refs #109435 Refs #109433 Refs #109432 Refs #108888 Refs #108770 Refs #108935 Refs #108873 Refs #109056 Refs #108982 Refs #108930 Refs #108902 Refs #108838 **Global `time.sleep` patches: `StreamDemoDataTest` (12).** `patch("sentry.conduit.tasks.time.sleep")` replaces `time.sleep` for every thread in the process, so any background thread that sleeps adds to the count (CI saw 451 to 5332 calls instead of 100). Both stream tests now patch the module-local `time` references instead: `sentry.conduit.tasks.time`, and in the retry test also `sentry.utils.retries.time`, where the retry policy sleeps. The real `time.time()` stays in place. The retry test now also checks both sleep counts. No other test in the file patches `time.sleep`. Refs #108842 **Batch deadline: `TestBatchedOccurrenceConsumer::test_issue_multiple_status_changes` (4).** Arroyo starts the batch deadline when the strategy is built, so with `max_batch_time=1` a slow run flushed the batch before all six messages arrived. The test now uses `sys.maxsize`, like its sibling tests since #88955. Refs #108997 **1 second rate limit windows: `test_impersonation_enforces_rate_limits_when_disabled` (1).** Impersonation limits use 1 second windows, so three calls that straddle a second boundary are counted in different windows. The calls now run under `freeze_time`, like the other tests in the file. `test_impersonation_with_custom_endpoint_config` has the same pattern and gets the same fix. Refs #109043 **Random collision: `test_ttl_jitter` (1).** Two random TTLs round to the same second about once in 1800 runs, and the test asserted they differ. It now checks the bounds with real draws and the spread with fixed draws. Refs #108952 ### Reproductions Each fix was checked with a deterministic reproduction, run against the parent commit (fails) and against the fix (passes). The uptime pagination test and the TTL jitter draws are committed. The others are not, because they force the failure by rewriting physical row order, pinning the snowflake clock, or adding a sleeping background thread. Those depend on planner behavior or test order, or test the test harness rather than the product. | Fix | Reproduction | Parent | Fixed | |---|---|---|---| | Uptime pagination | `test_paginates_in_id_order` | fail | pass | | Deploy footer | release_project rows in the opposite physical order to project rows | fail | pass | | Channel login | first org's mapping row rewritten to the end of the heap | fail | pass | | Snuba reset (3 classes) | snowflake clock pinned to one second, preceded by a test that writes feedback or events | fail | pass | | Conduit sleeps | background thread calling `time.sleep` during the test | fail | pass | | Batch deadline | arroyo clock advanced 2s after the strategy is built | fail | pass | | Impersonation windows | wall clock advanced 1s per rate limit check | fail | pass | | TTL jitter | `random.seed(1095)` | fail | pass | The ten changed test files (139 tests) also passed 20 consecutive runs with `-n 2 --dist=loadfile`, per-worker Snuba, and `PYTHONHASHSEED=0`, as in CI. <!-- junior-request-attribution:start --> via @joshuarli. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:agent%3A3f29e2341c39dfa3441598ed78b058a7 --> -- [View Junior Session](https://junior-prod.sentry.dev/conversations/agent%3A3f29e2341c39dfa3441598ed78b058a7) [[Sentry]](https://sentry.sentry.io/explore/conversations/agent%3A3f29e2341c39dfa3441598ed78b058a7/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: Joshua Li <14209781+joshuarli@users.noreply.github.com>
1 parent e669d6f commit b73b480

11 files changed

Lines changed: 97 additions & 27 deletions

File tree

‎src/sentry/uptime/endpoints/organiation_uptime_alert_index.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,5 @@ def get(
141141
queryset=queryset,
142142
on_results=lambda x: serialize(x, request.user, UptimeDetectorSerializer()),
143143
paginator_cls=OffsetPaginator,
144+
order_by="id",
144145
)

‎tests/sentry/conduit/test_tasks.py‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import datetime
22
import re
3+
import time
34
from unittest.mock import patch
45
from uuid import uuid4
56

@@ -256,9 +257,12 @@ class StreamDemoDataTest(TestCase):
256257
CONDUIT_PUBLISH_JWT_AUDIENCE="conduit",
257258
CONDUIT_PUBLISH_URL="http://localhost:9093",
258259
)
259-
@patch("sentry.conduit.tasks.time.sleep")
260-
def test_stream_demo_data_sends_all_phases(self, mock_sleep):
260+
# Patch the module's own `time` reference. Patching `time.sleep` would also count the sleeps of
261+
# every other thread in the test process.
262+
@patch("sentry.conduit.tasks.time")
263+
def test_stream_demo_data_sends_all_phases(self, mock_time):
261264
"""Test that stream_demo_data sends START, DELTA, and END phases."""
265+
mock_time.time.side_effect = time.time
262266
org_id = 123
263267
channel_id = str(uuid4())
264268

@@ -271,7 +275,7 @@ def test_stream_demo_data_sends_all_phases(self, mock_sleep):
271275
stream_demo_data(org_id=org_id, channel_id=channel_id)
272276

273277
assert len(responses.calls) == NUM_DELTAS + 2
274-
assert mock_sleep.call_count == NUM_DELTAS
278+
assert mock_time.sleep.call_count == NUM_DELTAS
275279

276280
@responses.activate
277281
@override_settings(
@@ -280,9 +284,13 @@ def test_stream_demo_data_sends_all_phases(self, mock_sleep):
280284
CONDUIT_PUBLISH_JWT_AUDIENCE="conduit",
281285
CONDUIT_PUBLISH_URL="http://localhost:9093",
282286
)
283-
@patch("sentry.conduit.tasks.time.sleep")
284-
def test_stream_demo_data_stops_on_exhausted_retries(self, mock_sleep):
287+
# Patch the module-local `time` references (the task's and the retry policy's) rather than
288+
# `time.sleep`, which would replace sleep for every thread in the test process.
289+
@patch("sentry.utils.retries.time")
290+
@patch("sentry.conduit.tasks.time")
291+
def test_stream_demo_data_stops_on_exhausted_retries(self, mock_time, mock_retries_time):
285292
"""Test that stream_demo_data stops streaming when a publish fails after max retries."""
293+
mock_time.time.side_effect = time.time
286294
org_id = 123
287295
channel_id = str(uuid4())
288296

@@ -305,3 +313,6 @@ def test_stream_demo_data_stops_on_exhausted_retries(self, mock_sleep):
305313
stream_demo_data(org_id=org_id, channel_id=channel_id)
306314

307315
assert len(responses.calls) == 10 + PUBLISH_REQUEST_MAX_RETRIES
316+
# START plus 9 deltas succeed, and the task sleeps after each delta.
317+
assert mock_time.sleep.call_count == 9
318+
assert mock_retries_time.sleep.call_count == PUBLISH_REQUEST_MAX_RETRIES - 1

‎tests/sentry/feedback/endpoints/test_organization_feedback_categories.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from datetime import datetime
22
from unittest.mock import patch
33

4+
import pytest
45
from django.urls import reverse
56

67
from sentry.feedback.lib.utils import FeedbackCreationSource
@@ -13,6 +14,9 @@
1314

1415

1516
@cell_silo_test
17+
# Snowflake org and project ids repeat when two tests start in the same second, so reset Snuba
18+
# rather than read back a previous test's rows for "our" project.
19+
@pytest.mark.usefixtures("reset_snuba")
1620
class OrganizationFeedbackCategoriesTest(APITestCase):
1721
endpoint = "sentry-api-0-organization-user-feedback-categories"
1822

‎tests/sentry/feedback/lib/test_label_query.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
from tests.sentry.feedback import mock_feedback_event
2121

2222

23+
# Snowflake org and project ids repeat when two tests start in the same second, so reset Snuba
24+
# rather than read back a previous test's rows for "our" project.
25+
@pytest.mark.usefixtures("reset_snuba")
2326
class TestLabelQuery(APITestCase):
2427
def setUp(self) -> None:
2528
super().setUp()

‎tests/sentry/integrations/slack/notifications/test_deploy.py‎

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,21 +50,22 @@ def test_deploy_block(self) -> None:
5050
)
5151
assert blocks[0]["text"]["text"] == fallback_text
5252

53-
first_project = None
54-
for i in range(len(projects)):
55-
project = SLUGS_TO_PROJECT[blocks[2]["elements"][i]["text"]["text"]]
56-
if not first_project:
57-
first_project = project
53+
actions = blocks[2]["elements"]
54+
assert sorted(action["text"]["text"] for action in actions) == sorted(SLUGS_TO_PROJECT)
55+
for action in actions:
56+
project = SLUGS_TO_PROJECT[action["text"]["text"]]
5857
assert (
59-
blocks[2]["elements"][i]["url"]
58+
action["url"]
6059
== f"http://testserver/organizations/{self.organization.slug}/releases/"
6160
f"{release.version}/?project={project.id}&unselectedSeries=Healthy&referrer=release_activity&notification_uuid={notification.notification_uuid}"
6261
)
63-
assert blocks[2]["elements"][i]["value"] == "link_clicked"
64-
assert first_project is not None
62+
assert action["value"] == "link_clicked"
6563

66-
# footer project is the first project in the actions list
64+
# The footer and the actions come from separate unordered queries, so the footer can
65+
# name any of the release's projects, not necessarily the first action's.
66+
footer_slug, footer_link = blocks[1]["elements"][0]["text"].split(" | ")
67+
assert footer_slug in SLUGS_TO_PROJECT
6768
assert (
68-
blocks[1]["elements"][0]["text"]
69-
== f"{first_project.slug} | <http://testserver/settings/account/notifications/deploy/?referrer=release_activity-slack-user&notification_uuid={notification.notification_uuid}|Notification Settings>"
69+
footer_link
70+
== f"<http://testserver/settings/account/notifications/deploy/?referrer=release_activity-slack-user&notification_uuid={notification.notification_uuid}|Notification Settings>"
7071
)

‎tests/sentry/issues/test_run.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -465,7 +465,9 @@ def test_issue_multiple_status_changes(
465465
strategy = OccurrenceStrategyFactory(
466466
mode="batched-parallel",
467467
max_batch_size=6,
468-
max_batch_time=1,
468+
# The batch deadline starts when the strategy is built, so a short one can split the
469+
# batch on a slow run.
470+
max_batch_time=sys.maxsize,
469471
).create_with_partitions(
470472
commit=mock_commit,
471473
partitions={},

‎tests/sentry/middleware/test_ratelimit_middleware.py‎

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -235,10 +235,14 @@ def test_impersonation_enforces_rate_limits_when_disabled(self) -> None:
235235
impersonator = self.create_user(email="impersonator@example.com")
236236
request.actual_user = impersonator
237237

238-
# Call this endpoint multiple times get hit by rate limit
239-
self.middleware.process_view(request, self._test_endpoint_no_rate_limits, [], {})
240-
self.middleware.process_view(request, self._test_endpoint_no_rate_limits, [], {})
241-
response = self.middleware.process_view(request, self._test_endpoint_no_rate_limits, [], {})
238+
# Call this endpoint multiple times get hit by rate limit. Freeze time so all calls land
239+
# in the same 1s impersonation window.
240+
with freeze_time("2000-01-01"):
241+
self.middleware.process_view(request, self._test_endpoint_no_rate_limits, [], {})
242+
self.middleware.process_view(request, self._test_endpoint_no_rate_limits, [], {})
243+
response = self.middleware.process_view(
244+
request, self._test_endpoint_no_rate_limits, [], {}
245+
)
242246

243247
assert response is not None
244248
assert isinstance(response, HttpResponse)
@@ -289,10 +293,12 @@ def get(self):
289293
impersonator = self.create_user(email="impersonator@example.com")
290294
request.actual_user = impersonator
291295

292-
# Call with an endpoint that normally doesn't enforce rate limits multiple times to get hit by rate limit
293-
self.middleware.process_view(request, high_limit_view, [], {})
294-
self.middleware.process_view(request, high_limit_view, [], {})
295-
response = self.middleware.process_view(request, high_limit_view, [], {})
296+
# Call with an endpoint that normally doesn't enforce rate limits multiple times to get hit by rate limit.
297+
# Freeze time so all calls land in the same 1s impersonation window.
298+
with freeze_time("2000-01-01"):
299+
self.middleware.process_view(request, high_limit_view, [], {})
300+
self.middleware.process_view(request, high_limit_view, [], {})
301+
response = self.middleware.process_view(request, high_limit_view, [], {})
296302

297303
# During impersonation, should get a 429 response
298304
assert response is not None

‎tests/sentry/sentry_metrics/test_indexer_cache.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
from datetime import timedelta
2+
from unittest.mock import patch
23

34
import pytest
45
from django.conf import settings
@@ -216,7 +217,14 @@ def test_ttl_jitter() -> None:
216217
ttl_2 = indexer_cache.randomized_ttl
217218
assert base_ttl <= ttl_2 <= max_ttl
218219

219-
assert not ttl_1 == ttl_2
220+
# Two real draws land on the same whole second about once in 1800 runs, so check the spread
221+
# with fixed draws instead.
222+
with patch("sentry.sentry_metrics.indexer.cache.random") as mock_random:
223+
mock_random.uniform.side_effect = [0.0, 0.1, 0.25]
224+
assert indexer_cache.randomized_ttl == base_ttl
225+
assert indexer_cache.randomized_ttl == base_ttl + 720
226+
assert indexer_cache.randomized_ttl == max_ttl
227+
mock_random.uniform.assert_called_with(0, 0.25)
220228

221229

222230
def test_separate_namespacing() -> None:

‎tests/sentry/services/eventstore/test_models.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,9 @@ def timezone_context(tz: str):
4444
time.tzset()
4545

4646

47+
# Snowflake org and project ids repeat when two tests start in the same second, so reset Snuba
48+
# rather than read back a previous test's rows for "our" project.
49+
@pytest.mark.usefixtures("reset_snuba")
4750
class EventTest(TestCase, PerformanceIssueTestCase):
4851
def test_pickling_compat(self) -> None:
4952
event = self.store_event(

‎tests/sentry/uptime/endpoints/test_organization_uptime_alert_index.py‎

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from django.db import connections, router
2+
13
from sentry.api.serializers import serialize
24
from sentry.constants import ObjectStatus
35
from sentry.uptime.endpoints.serializers import UptimeDetectorSerializer
6+
from sentry.workflow_engine.models import Detector
47
from tests.sentry.uptime.endpoints import UptimeAlertBaseEndpointTest
58

69

@@ -23,6 +26,30 @@ def test(self) -> None:
2326
resp = self.get_success_response(self.organization.slug)
2427
self.check_valid_response(resp, [alert_1, alert_2])
2528

29+
def test_paginates_in_id_order(self) -> None:
30+
detectors = [self.create_uptime_detector(name=f"test{i}") for i in range(5)]
31+
# Rewrite the rows newest first, so their physical order, which an unordered query
32+
# follows, no longer matches id order.
33+
with connections[router.db_for_write(Detector)].cursor() as cursor:
34+
for detector in reversed(detectors):
35+
cursor.execute(
36+
"WITH moved AS (DELETE FROM workflow_engine_detector WHERE id = %s RETURNING *) "
37+
"INSERT INTO workflow_engine_detector SELECT * FROM moved",
38+
[detector.id],
39+
)
40+
41+
response = self.get_success_response(self.organization.slug, per_page=2)
42+
self.check_valid_response(response, detectors[0:2])
43+
response = self.get_success_response(
44+
self.organization.slug, per_page=2, cursor=self.get_cursor_headers(response)[1]
45+
)
46+
self.check_valid_response(response, detectors[2:4])
47+
response = self.get_success_response(
48+
self.organization.slug, per_page=2, cursor=self.get_cursor_headers(response)[1]
49+
)
50+
self.check_valid_response(response, detectors[4:5])
51+
assert 'rel="next"; results="false"' in response["Link"]
52+
2653
def test_search_by_url(self) -> None:
2754
self.create_uptime_detector()
2855
santry_monitor = self.create_uptime_detector(

0 commit comments

Comments
 (0)