Browse Source

Project Setting Template Options Manager (#73392)

## Description
[Tech
Spec](https://www.notion.so/sentry/Service-Settings-Templates-ca6fdb38b15f4a6db61d319bc342b86c?pvs=4#8cddeba5606d43f49c0d80a71bcaaedb)
This is the implementation of a caching layer for
ProjectTemplateOptions. This layer will then be used will be used to
create the layering effect (described in tech spec) for the `Project`
model.

This code is meant to replicate the
[ProjectOptionsManager](https://github.com/getsentry/sentry/blob/a2c96d4822961b76b71f40e9cb82f14277104a8f/src/sentry/models/options/project_option.py#L79),
but there are a few minor differences. These differences are why I ended
up replicating the system rather than trying to create a single manager.

Currently this code is not executable in production.
Josh Callender 4 days ago
parent
commit
ebddda6a3a

+ 84 - 0
src/sentry/models/options/project_template_option.py

@@ -1,8 +1,90 @@
+from __future__ import annotations
+
+from collections.abc import Callable, Mapping, Sequence
+from typing import Any, ClassVar
+
 from django.db import models
 
 from sentry.backup.scopes import RelocationScope
 from sentry.db.models import FlexibleForeignKey, Model, region_silo_model, sane_repr
 from sentry.db.models.fields import PickledObjectField
+from sentry.db.models.manager.option import OptionManager
+from sentry.models.projecttemplate import ProjectTemplate
+from sentry.utils.cache import cache
+
+Value = Any | None
+
+
+class ProjectTemplateOptionManager(OptionManager["ProjectTemplateOption"]):
+    def get_value_bulk(
+        self, instance: Sequence[ProjectTemplate], key: str
+    ) -> Mapping[ProjectTemplate, Value]:
+        result = cache.get_many([self._make_key(i.id) for i in instance])
+
+        if not result:
+            return {}
+
+        return {i: result.get(self._make_key(i.id), {}).get(key) for i in instance}
+
+    def get_value(
+        self,
+        project_template: ProjectTemplate | int,
+        key: str,
+        default: Value = None,
+        validate: Callable[[object], bool] | None = None,
+    ) -> Value:
+        result = self.get_all_values(project_template)
+
+        if key in result:
+            if validate is None or validate(result[key]):
+                return result[key]
+
+        return default
+
+    def unset_value(self, project_template: ProjectTemplate, key: str) -> None:
+        self.filter(project_template=project_template, key=key).delete()
+        self.reload_cache(project_template.id, "projecttemplateoption.unset_value")
+
+    def set_value(self, project_template: ProjectTemplate, key: str, value: Value) -> bool:
+        project_template_id = project_template.id
+
+        inst, created = self.create_or_update(
+            project_template_id=project_template_id, key=key, values={"value": value}
+        )
+        self.reload_cache(project_template_id, "projectoption.set_value")
+
+        return created or inst > 0
+
+    def get_all_values(self, project_template: ProjectTemplate | int) -> Mapping[str, Value]:
+        if isinstance(project_template, models.Model):
+            project_template_id = project_template.id
+        else:
+            project_template_id = project_template
+        cache_key = self._make_key(project_template_id)
+
+        if cache_key not in self._option_cache:
+            result = cache.get(cache_key)
+
+            if result is None:
+                self.reload_cache(project_template_id, "projecttemplateoption.get_all_values")
+            else:
+                self._option_cache[cache_key] = result
+
+        return self._option_cache.get(cache_key, {})
+
+    def reload_cache(self, project_template_id: int, update_reason: str) -> None:
+        cache_key = self._make_key(project_template_id)
+
+        result = {i.key: i.value for i in self.filter(project_template=project_template_id)}
+
+        cache.set(cache_key, result)
+        self._option_cache[cache_key] = result
+
+    def post_save(self, instance: ProjectTemplateOption, **kwargs: Any) -> None:
+        self.reload_cache(instance.project_template_id, "projecttemplateoption.post_save")
+
+    def post_delete(self, instance: ProjectTemplateOption, **kwargs: Any) -> None:
+        self.reload_cache(instance.project_template_id, "projecttemplateoption.post_delete")
 
 
 @region_silo_model
@@ -20,6 +102,8 @@ class ProjectTemplateOption(Model):
     key = models.CharField(max_length=64)
     value = PickledObjectField()
 
+    objects: ClassVar[ProjectTemplateOptionManager] = ProjectTemplateOptionManager()
+
     class Meta:
         app_label = "sentry"
         db_table = "sentry_projecttemplateoption"

+ 127 - 0
tests/sentry/models/test_projecttemplateoption.py

@@ -3,6 +3,133 @@ from sentry.models.projecttemplate import ProjectTemplate
 from sentry.testutils.cases import TestCase
 
 
+class ProjectTemplateOptionManagerTest(TestCase):
+    def setUp(self):
+        self.org = self.create_organization()
+
+        self.project_template = ProjectTemplate.objects.create(
+            name="test_project_template", organization=self.org
+        )
+
+    def test_set_value(self):
+        ProjectTemplateOption.objects.set_value(self.project_template, "foo", "bar")
+        assert (
+            ProjectTemplateOption.objects.get(
+                project_template=self.project_template, key="foo"
+            ).value
+            == "bar"
+        )
+
+    def test_get_value(self):
+        result = ProjectTemplateOption.objects.get_value(self.project_template, "foo")
+        assert result is None
+
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+
+        result = ProjectTemplateOption.objects.get_value(self.project_template, "foo")
+        assert result == "bar"
+
+    def test_get_value_validated(self):
+        result = ProjectTemplateOption.objects.get_value(self.project_template, "foo")
+        assert result is None
+
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+
+        # Test validator true
+        result = ProjectTemplateOption.objects.get_value(
+            self.project_template, "foo", None, lambda x: x == "bar"
+        )
+        assert result == "bar"
+
+        # Test validator false
+        result = ProjectTemplateOption.objects.get_value(
+            self.project_template, "foo", None, lambda x: x != "bar"
+        )
+        assert result is None
+
+    def test_unset_value(self):
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+        ProjectTemplateOption.objects.unset_value(self.project_template, "foo")
+        assert not ProjectTemplateOption.objects.filter(
+            project_template=self.project_template, key="foo"
+        ).exists()
+
+    def test_get_value_bulk(self):
+        result = ProjectTemplateOption.objects.get_value_bulk([self.project_template], "foo")
+        assert result == {}
+
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+        result = ProjectTemplateOption.objects.get_value_bulk([self.project_template], "foo")
+        assert result == {self.project_template: "bar"}
+
+    def test_get_value_actually_bulk(self):
+        second_template = ProjectTemplate.objects.create(
+            name="second_test_project_template", organization=self.org
+        )
+
+        result = ProjectTemplateOption.objects.get_value_bulk(
+            [self.project_template, second_template], "foo"
+        )
+        assert result == {}
+
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+        ProjectTemplateOption.objects.create(
+            project_template=second_template, key="foo", value="baz"
+        )
+        result = ProjectTemplateOption.objects.get_value_bulk(
+            [self.project_template, second_template], "foo"
+        )
+        assert result == {self.project_template: "bar", second_template: "baz"}
+
+    def test_update_value(self):
+        result = ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+        assert result.value == "bar"
+
+        ProjectTemplateOption.objects.set_value(self.project_template, key="foo", value="baz")
+        result = ProjectTemplateOption.objects.get(
+            project_template=self.project_template, key="foo"
+        )
+        assert result.value == "baz"
+
+    def test_get_all_values(self):
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+
+        result = ProjectTemplateOption.objects.get_all_values(self.project_template)
+        assert result == {"foo": "bar"}
+
+    def test_get_all_values_id(self):
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+
+        result = ProjectTemplateOption.objects.get_all_values(self.project_template.id)
+        assert result == {"foo": "bar"}
+
+    def test_get_all_values_without_cache(self):
+        ProjectTemplateOption.objects.create(
+            project_template=self.project_template, key="foo", value="bar"
+        )
+
+        ProjectTemplateOption.objects._option_cache.clear()
+
+        result = ProjectTemplateOption.objects.get_all_values(self.project_template.id)
+        assert result == {"foo": "bar"}
+
+
 class ProjectTemplateOptionTest(TestCase):
     def setUp(self):
         self.org = self.create_organization()