|
@@ -1,6 +1,8 @@
|
|
|
+import contextlib
|
|
|
from unittest.mock import patch
|
|
|
|
|
|
import pytest
|
|
|
+from django.db import transaction
|
|
|
|
|
|
from sentry.models import Project, ProjectKey, ProjectKeyStatus, ProjectOption
|
|
|
from sentry.relay.projectconfig_cache.redis import RedisProjectConfigCache
|
|
@@ -27,6 +29,45 @@ def _cache_keys_for_org(org):
|
|
|
yield key.public_key
|
|
|
|
|
|
|
|
|
+@pytest.fixture
|
|
|
+def emulate_transactions(burst_task_runner, django_capture_on_commit_callbacks):
|
|
|
+ # This contraption helps in testing the usage of `transaction.on_commit` in
|
|
|
+ # schedule_update_config_cache. Normally tests involving transactions would
|
|
|
+ # require us to use the transactional testcase (or
|
|
|
+ # `pytest.mark.django_db(transaction=True)`), but that incurs a 2x slowdown
|
|
|
+ # in test speed and we're trying to keep our testcases fast.
|
|
|
+ @contextlib.contextmanager
|
|
|
+ def inner(assert_num_callbacks=1):
|
|
|
+ with burst_task_runner() as burst:
|
|
|
+ with django_capture_on_commit_callbacks(execute=True) as callbacks:
|
|
|
+ yield
|
|
|
+
|
|
|
+ # Assert there are no relay-related jobs in the queue yet, as we should have
|
|
|
+ # some on_commit callbacks instead. If we don't, then the model
|
|
|
+ # hook has scheduled the update_config_cache task prematurely.
|
|
|
+ #
|
|
|
+ # Remove any other jobs from the queue that may have been triggered via model hooks
|
|
|
+ assert not any("relay" in task.__name__ for task, _, _ in burst.queue)
|
|
|
+ burst.queue.clear()
|
|
|
+
|
|
|
+ # for some reason, the callbacks array is only populated by
|
|
|
+ # pytest-django's implementation after the context manager has
|
|
|
+ # exited, not while they are being registered
|
|
|
+ assert len(callbacks) == assert_num_callbacks
|
|
|
+
|
|
|
+ # Callbacks have been executed, job(s) should've been scheduled now, so
|
|
|
+ # let's execute them.
|
|
|
+ #
|
|
|
+ # Note: We can't directly assert that the data race has not occured, as
|
|
|
+ # there are no real DB transactions available in this testcase. The
|
|
|
+ # entire test runs in one transaction because that's how pytest-django
|
|
|
+ # sets up things unless one uses
|
|
|
+ # pytest.mark.django_db(transaction=True).
|
|
|
+ burst(max_jobs=10)
|
|
|
+
|
|
|
+ return inner
|
|
|
+
|
|
|
+
|
|
|
@pytest.fixture
|
|
|
def redis_cache(monkeypatch):
|
|
|
monkeypatch.setattr(
|
|
@@ -97,13 +138,11 @@ def test_generate(
|
|
|
default_project,
|
|
|
default_organization,
|
|
|
default_projectkey,
|
|
|
- task_runner,
|
|
|
redis_cache,
|
|
|
):
|
|
|
assert not redis_cache.get(default_projectkey.public_key)
|
|
|
|
|
|
- with task_runner():
|
|
|
- build_project_config(default_projectkey.public_key)
|
|
|
+ build_project_config(default_projectkey.public_key)
|
|
|
|
|
|
cfg = redis_cache.get(default_projectkey.public_key)
|
|
|
|
|
@@ -120,8 +159,11 @@ def test_generate(
|
|
|
|
|
|
|
|
|
@pytest.mark.django_db
|
|
|
-def test_project_update_option(default_projectkey, default_project, task_runner, redis_cache):
|
|
|
- with task_runner():
|
|
|
+def test_project_update_option(
|
|
|
+ default_projectkey, default_project, emulate_transactions, redis_cache
|
|
|
+):
|
|
|
+ # XXX: there should only be one hook triggered, regardless of debouncing
|
|
|
+ with emulate_transactions(assert_num_callbacks=4):
|
|
|
default_project.update_option(
|
|
|
"sentry:relay_pii_config", '{"applications": {"$string": ["@creditcard:mask"]}}'
|
|
|
)
|
|
@@ -130,7 +172,8 @@ def test_project_update_option(default_projectkey, default_project, task_runner,
|
|
|
"applications": {"$string": ["@creditcard:mask"]}
|
|
|
}
|
|
|
|
|
|
- with task_runner():
|
|
|
+ # XXX: there should only be one hook triggered, regardless of debouncing
|
|
|
+ with emulate_transactions(assert_num_callbacks=2):
|
|
|
default_project.organization.update_option(
|
|
|
"sentry:relay_pii_config", '{"applications": {"$string": ["@creditcard:mask"]}}'
|
|
|
)
|
|
@@ -140,8 +183,9 @@ def test_project_update_option(default_projectkey, default_project, task_runner,
|
|
|
|
|
|
|
|
|
@pytest.mark.django_db
|
|
|
-def test_project_delete_option(default_project, task_runner, redis_cache):
|
|
|
- with task_runner():
|
|
|
+def test_project_delete_option(default_project, emulate_transactions, redis_cache):
|
|
|
+ # XXX: there should only be one hook triggered, regardless of debouncing
|
|
|
+ with emulate_transactions(assert_num_callbacks=3):
|
|
|
default_project.delete_option("sentry:relay_pii_config")
|
|
|
|
|
|
for cache_key in _cache_keys_for_project(default_project):
|
|
@@ -149,9 +193,9 @@ def test_project_delete_option(default_project, task_runner, redis_cache):
|
|
|
|
|
|
|
|
|
@pytest.mark.django_db
|
|
|
-def test_project_get_option_does_not_reload(default_project, task_runner, monkeypatch):
|
|
|
+def test_project_get_option_does_not_reload(default_project, emulate_transactions, monkeypatch):
|
|
|
ProjectOption.objects._option_cache.clear()
|
|
|
- with task_runner():
|
|
|
+ with emulate_transactions(assert_num_callbacks=0):
|
|
|
with patch("sentry.utils.cache.cache.get", return_value=None):
|
|
|
with patch("sentry.tasks.relay.schedule_build_project_config") as build_project_config:
|
|
|
default_project.get_option(
|
|
@@ -162,7 +206,7 @@ def test_project_get_option_does_not_reload(default_project, task_runner, monkey
|
|
|
|
|
|
|
|
|
@pytest.mark.django_db
|
|
|
-def test_invalidation_project_deleted(default_project, task_runner, redis_cache):
|
|
|
+def test_invalidation_project_deleted(default_project, emulate_transactions, redis_cache):
|
|
|
# Ensure we have a ProjectKey
|
|
|
project_key = next(_cache_keys_for_project(default_project))
|
|
|
assert project_key
|
|
@@ -174,7 +218,7 @@ def test_invalidation_project_deleted(default_project, task_runner, redis_cache)
|
|
|
project_id = default_project.id
|
|
|
|
|
|
# Delete the project normally, this will delete it from the cache
|
|
|
- with task_runner():
|
|
|
+ with emulate_transactions(assert_num_callbacks=4):
|
|
|
default_project.delete()
|
|
|
assert redis_cache.get(project_key) is None
|
|
|
|
|
@@ -184,10 +228,12 @@ def test_invalidation_project_deleted(default_project, task_runner, redis_cache)
|
|
|
|
|
|
|
|
|
@pytest.mark.django_db
|
|
|
-def test_projectkeys(default_project, task_runner, redis_cache):
|
|
|
+def test_projectkeys(default_project, emulate_transactions, redis_cache):
|
|
|
# When a projectkey is deleted the invalidation task should be triggered and the project
|
|
|
# should be cached as disabled.
|
|
|
- with task_runner():
|
|
|
+
|
|
|
+ # XXX: there should only be one hook triggered, regardless of debouncing
|
|
|
+ with emulate_transactions(assert_num_callbacks=2):
|
|
|
deleted_pks = list(ProjectKey.objects.filter(project=default_project))
|
|
|
for key in deleted_pks:
|
|
|
key.delete()
|
|
@@ -201,13 +247,13 @@ def test_projectkeys(default_project, task_runner, redis_cache):
|
|
|
(pk_json,) = redis_cache.get(pk.public_key)["publicKeys"]
|
|
|
assert pk_json["publicKey"] == pk.public_key
|
|
|
|
|
|
- with task_runner():
|
|
|
+ with emulate_transactions():
|
|
|
pk.status = ProjectKeyStatus.INACTIVE
|
|
|
pk.save()
|
|
|
|
|
|
assert redis_cache.get(pk.public_key)["disabled"]
|
|
|
|
|
|
- with task_runner():
|
|
|
+ with emulate_transactions():
|
|
|
pk.delete()
|
|
|
|
|
|
assert redis_cache.get(pk.public_key) is None
|
|
@@ -216,6 +262,38 @@ def test_projectkeys(default_project, task_runner, redis_cache):
|
|
|
assert not redis_cache.get(key.public_key)
|
|
|
|
|
|
|
|
|
+@pytest.mark.django_db(transaction=True)
|
|
|
+def test_db_transaction(default_project, default_projectkey, redis_cache, task_runner):
|
|
|
+ with task_runner(), transaction.atomic():
|
|
|
+ default_project.update_option(
|
|
|
+ "sentry:relay_pii_config", '{"applications": {"$string": ["@creditcard:mask"]}}'
|
|
|
+ )
|
|
|
+
|
|
|
+ # Assert that cache entry hasn't been created yet, only after the
|
|
|
+ # transaction has committed.
|
|
|
+ assert not redis_cache.get(default_projectkey.public_key)
|
|
|
+
|
|
|
+ assert redis_cache.get(default_projectkey.public_key)["config"]["piiConfig"] == {
|
|
|
+ "applications": {"$string": ["@creditcard:mask"]}
|
|
|
+ }
|
|
|
+
|
|
|
+ try:
|
|
|
+ with task_runner(), transaction.atomic():
|
|
|
+ default_project.update_option(
|
|
|
+ "sentry:relay_pii_config", '{"applications": {"$string": ["@password:mask"]}}'
|
|
|
+ )
|
|
|
+
|
|
|
+ raise Exception("rollback!")
|
|
|
+
|
|
|
+ except Exception:
|
|
|
+ pass
|
|
|
+
|
|
|
+ # Assert that database rollback is honored
|
|
|
+ assert redis_cache.get(default_projectkey.public_key)["config"]["piiConfig"] == {
|
|
|
+ "applications": {"$string": ["@creditcard:mask"]}
|
|
|
+ }
|
|
|
+
|
|
|
+
|
|
|
class TestInvalidationTask:
|
|
|
@pytest.fixture
|
|
|
def debounce_cache(self, monkeypatch):
|
|
@@ -312,8 +390,8 @@ class TestInvalidationTask:
|
|
|
default_project,
|
|
|
default_organization,
|
|
|
default_projectkey,
|
|
|
- task_runner,
|
|
|
redis_cache,
|
|
|
+ task_runner,
|
|
|
):
|
|
|
# Currently for org-wide we delete the config instead of computing it.
|
|
|
cfg = {"dummy-key": "val"}
|