Browse Source

Revert "ref(native): Remove all references to symcaches and cficaches (#13248)" (#13265)

This reverts commit 4ae98be455cd51cc37c23c3b5ff58e8f296c24b3.
Jan Michael Auer 5 years ago
parent
commit
d6d1cb7280

+ 2 - 5
src/sentry/deletions/defaults/project.py

@@ -44,11 +44,8 @@ class ProjectDeletionTask(ModelDeletionTask):
         # in bulk
         # in bulk
         # Release needs to handle deletes after Group is cleaned up as the foreign
         # Release needs to handle deletes after Group is cleaned up as the foreign
         # key is protected
         # key is protected
-        model_list = (
-            models.Group,
-            models.ReleaseProject,
-            models.ReleaseProjectEnvironment,
-            models.ProjectDebugFile)
+        model_list = (models.Group, models.ReleaseProject, models.ReleaseProjectEnvironment, models.ProjectDebugFile,
+                      models.ProjectSymCacheFile)
         relations.extend(
         relations.extend(
             [ModelRelation(m, {'project_id': instance.id}, ModelDeletionTask) for m in model_list]
             [ModelRelation(m, {'project_id': instance.id}, ModelDeletionTask) for m in model_list]
         )
         )

+ 336 - 5
src/sentry/models/debugfile.py

@@ -20,10 +20,13 @@ import logging
 import tempfile
 import tempfile
 
 
 from jsonfield import JSONField
 from jsonfield import JSONField
-from django.db import models
+from django.db import models, transaction, IntegrityError
 from django.db.models.fields.related import OneToOneRel
 from django.db.models.fields.related import OneToOneRel
 
 
-from symbolic import Archive, SymbolicError, ObjectErrorUnsupportedObject
+from symbolic import Archive, SymbolicError, ObjectErrorUnsupportedObject, \
+    SYMCACHE_LATEST_VERSION, SymCache, SymCacheErrorMissingDebugInfo, \
+    SymCacheErrorMissingDebugSection, CfiCache, CfiErrorMissingDebugInfo, \
+    CFICACHE_LATEST_VERSION
 
 
 from sentry import options
 from sentry import options
 from sentry.cache import default_cache
 from sentry.cache import default_cache
@@ -33,7 +36,9 @@ from sentry.db.models import FlexibleForeignKey, Model, \
 from sentry.models.file import File
 from sentry.models.file import File
 from sentry.reprocessing import resolve_processing_issue, \
 from sentry.reprocessing import resolve_processing_issue, \
     bump_reprocessing_revision
     bump_reprocessing_revision
+from sentry.utils import metrics
 from sentry.utils.zip import safe_extract_zip
 from sentry.utils.zip import safe_extract_zip
+from sentry.utils.decorators import classproperty
 
 
 
 
 logger = logging.getLogger(__name__)
 logger = logging.getLogger(__name__)
@@ -187,12 +192,39 @@ class ProjectDebugFile(Model):
 
 
         return ''
         return ''
 
 
+    @property
+    def supports_caches(self):
+        return ProjectSymCacheFile.computes_from(self) \
+            or ProjectCfiCacheFile.computes_from(self)
+
     @property
     @property
     def features(self):
     def features(self):
         return frozenset((self.data or {}).get('features', []))
         return frozenset((self.data or {}).get('features', []))
 
 
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
-        super(ProjectDebugFile, self).delete(*args, **kwargs)
+        dif_id = self.id
+
+        with transaction.atomic():
+            # First, delete the debug file entity. This ensures no other
+            # worker can attach caches to it. Integrity checks are deferred
+            # within this transaction, so existing caches stay intact.
+            super(ProjectDebugFile, self).delete(*args, **kwargs)
+
+            # Explicitly select referencing caches and delete them. Using
+            # the backref does not work, since `dif.id` is None after the
+            # delete.
+            symcaches = ProjectSymCacheFile.objects \
+                .filter(debug_file_id=dif_id) \
+                .select_related('cache_file')
+            for symcache in symcaches:
+                symcache.delete()
+
+            cficaches = ProjectCfiCacheFile.objects \
+                .filter(debug_file_id=dif_id) \
+                .select_related('cache_file')
+            for cficache in cficaches:
+                cficache.delete()
+
         self.file.delete()
         self.file.delete()
 
 
 
 
@@ -218,6 +250,46 @@ class ProjectCacheFile(Model):
         unique_together = (('project', 'debug_file'),)
         unique_together = (('project', 'debug_file'),)
         app_label = 'sentry'
         app_label = 'sentry'
 
 
+    @classproperty
+    def ignored_errors(cls):
+        """Returns a set of errors that can safely be ignored during conversion.
+        These errors should be expected by bad input data and do not indicate a
+        programming error.
+        """
+        raise NotImplementedError
+
+    @classproperty
+    def required_features(cls):
+        """Returns a set of features object files must have in order to support
+        generating this cache.
+        """
+        raise NotImplementedError
+
+    @classproperty
+    def cache_cls(cls):
+        """Returns the class of the raw cache referenced by this model. It can
+        be used to load caches from the file system or to convert object files.
+        """
+        raise NotImplementedError
+
+    @classproperty
+    def cache_name(cls):
+        """Returns the name of the cache class in lower case. Can be used for
+        file extensions, cache keys, logs, etc.
+        """
+        return cls.cache_cls.__name__.lower()
+
+    @classmethod
+    def computes_from(cls, debug_file):
+        """Indicates whether the cache can be computed from the given DIF."""
+        return set(cls.required_features) <= debug_file.features
+
+    @property
+    def outdated(self):
+        """Indicates whether this cache is outdated and needs to be recomputed.
+        """
+        raise NotImplemented
+
     def delete(self, *args, **kwargs):
     def delete(self, *args, **kwargs):
         super(ProjectCacheFile, self).delete(*args, **kwargs)
         super(ProjectCacheFile, self).delete(*args, **kwargs)
         self.cache_file.delete()
         self.cache_file.delete()
@@ -229,6 +301,29 @@ class ProjectSymCacheFile(ProjectCacheFile):
     class Meta(ProjectCacheFile.Meta):
     class Meta(ProjectCacheFile.Meta):
         db_table = 'sentry_projectsymcachefile'
         db_table = 'sentry_projectsymcachefile'
 
 
+    @classproperty
+    def ignored_errors(cls):
+        return (SymCacheErrorMissingDebugSection, SymCacheErrorMissingDebugInfo)
+
+    @classproperty
+    def required_features(cls):
+        return ('debug',)
+
+    @classproperty
+    def cache_cls(cls):
+        return SymCache
+
+    @classmethod
+    def computes_from(cls, debug_file):
+        if debug_file.data is None:
+            # Compatibility with legacy DIFs before features were introduced
+            return debug_file.file_format in ('breakpad', 'macho', 'elf')
+        return super(ProjectSymCacheFile, cls).computes_from(debug_file)
+
+    @property
+    def outdated(self):
+        return self.version != SYMCACHE_LATEST_VERSION
+
 
 
 class ProjectCfiCacheFile(ProjectCacheFile):
 class ProjectCfiCacheFile(ProjectCacheFile):
     """Cache for stack unwinding information: CfiCache."""
     """Cache for stack unwinding information: CfiCache."""
@@ -236,6 +331,22 @@ class ProjectCfiCacheFile(ProjectCacheFile):
     class Meta(ProjectCacheFile.Meta):
     class Meta(ProjectCacheFile.Meta):
         db_table = 'sentry_projectcficachefile'
         db_table = 'sentry_projectcficachefile'
 
 
+    @classproperty
+    def ignored_errors(cls):
+        return (CfiErrorMissingDebugInfo,)
+
+    @classproperty
+    def required_features(cls):
+        return ('unwind',)
+
+    @classproperty
+    def cache_cls(cls):
+        return CfiCache
+
+    @property
+    def outdated(self):
+        return self.version != CFICACHE_LATEST_VERSION
+
 
 
 def clean_redundant_difs(project, debug_id):
 def clean_redundant_difs(project, debug_id):
     """Deletes redundant debug files from the database and file storage. A debug
     """Deletes redundant debug files from the database and file storage. A debug
@@ -276,7 +387,7 @@ def create_dif_from_id(project, meta, fileobj=None, file=None):
         checksum = file.checksum
         checksum = file.checksum
     elif fileobj is not None:
     elif fileobj is not None:
         h = hashlib.sha1()
         h = hashlib.sha1()
-        while True:
+        while 1:
             chunk = fileobj.read(16384)
             chunk = fileobj.read(16384)
             if not chunk:
             if not chunk:
                 break
                 break
@@ -428,7 +539,7 @@ def create_debug_file_from_dif(to_create, project):
     return rv
     return rv
 
 
 
 
-def create_files_from_dif_zip(fileobj, project):
+def create_files_from_dif_zip(fileobj, project, update_caches=True):
     """Creates all missing debug files from the given zip file.  This
     """Creates all missing debug files from the given zip file.  This
     returns a list of all files created.
     returns a list of all files created.
     """
     """
@@ -451,6 +562,16 @@ def create_files_from_dif_zip(fileobj, project):
 
 
         rv = create_debug_file_from_dif(to_create, project)
         rv = create_debug_file_from_dif(to_create, project)
 
 
+        # Trigger generation of symcaches and cficaches to avoid dogpiling when
+        # events start coming in.
+        if update_caches:
+            from sentry.tasks.symcache_update import symcache_update
+            ids_to_update = [six.text_type(dif.debug_id) for dif in rv
+                             if dif.supports_caches]
+            if ids_to_update:
+                symcache_update.delay(project_id=project.id,
+                                      debug_ids=ids_to_update)
+
         # Uploading new dsysm changes the reprocessing revision
         # Uploading new dsysm changes the reprocessing revision
         bump_reprocessing_revision(project)
         bump_reprocessing_revision(project)
 
 
@@ -467,6 +588,37 @@ class DIFCache(object):
     def get_project_path(self, project):
     def get_project_path(self, project):
         return os.path.join(self.cache_path, six.text_type(project.id))
         return os.path.join(self.cache_path, six.text_type(project.id))
 
 
+    def update_caches(self, project, debug_ids):
+        """Updates symcaches and cficaches for all debug files matching the
+        given debug ids, if the respective files support any of those caches.
+        """
+        # XXX: Worst case, this might download the same DIF twice.
+        self._get_caches_impl(project, debug_ids, ProjectSymCacheFile)
+        self._get_caches_impl(project, debug_ids, ProjectCfiCacheFile)
+
+    def get_symcaches(self, project, debug_ids, on_dif_referenced=None,
+                      with_conversion_errors=False):
+        """Loads symcaches for the given debug IDs from the file system cache or
+        blob store."""
+        cachefiles, conversion_errors = self._get_caches_impl(
+            project, debug_ids, ProjectSymCacheFile, on_dif_referenced)
+
+        symcaches = self._load_cachefiles_via_fs(project, cachefiles, SymCache)
+        if with_conversion_errors:
+            return symcaches, dict((k, v) for k, v in conversion_errors.items())
+        return symcaches
+
+    def get_cficaches(self, project, debug_ids, on_dif_referenced=None,
+                      with_conversion_errors=False):
+        """Loads cficaches for the given debug IDs from the file system cache or
+        blob store."""
+        cachefiles, conversion_errors = self._get_caches_impl(
+            project, debug_ids, ProjectCfiCacheFile, on_dif_referenced)
+        cficaches = self._load_cachefiles_via_fs(project, cachefiles, CfiCache)
+        if with_conversion_errors:
+            return cficaches, dict((k, v) for k, v in conversion_errors.items())
+        return cficaches
+
     def fetch_difs(self, project, debug_ids, features=None):
     def fetch_difs(self, project, debug_ids, features=None):
         """Given some ids returns an id to path mapping for where the
         """Given some ids returns an id to path mapping for where the
         debug symbol files are on the FS.
         debug symbol files are on the FS.
@@ -487,6 +639,185 @@ class DIFCache(object):
 
 
         return rv
         return rv
 
 
+    def _get_caches_impl(self, project, debug_ids, cls, on_dif_referenced=None):
+        # Fetch debug files first and invoke the callback if we need
+        debug_ids = [six.text_type(debug_id).lower() for debug_id in debug_ids]
+        debug_files = ProjectDebugFile.objects.find_by_debug_ids(
+            project, debug_ids, features=cls.required_features)
+
+        # Notify the caller that we have used a symbol file
+        if on_dif_referenced is not None:
+            for debug_file in six.itervalues(debug_files):
+                on_dif_referenced(debug_file)
+
+        # Now find all the cache files we already have
+        found_ids = [d.id for d in six.itervalues(debug_files)]
+        existing_caches = cls.objects \
+            .filter(project=project, debug_file_id__in=found_ids) \
+            .select_related('cache_file', 'debug_file__debug_id')
+
+        # Check for missing and out-of-date cache files. Outdated files are
+        # removed to be re-created immediately.
+        caches = []
+        to_update = debug_files.copy()
+        for cache_file in existing_caches:
+            if cache_file.outdated:
+                cache_file.delete()
+            else:
+                debug_id = cache_file.debug_file.debug_id
+                to_update.pop(debug_id, None)
+                caches.append((debug_id, cache_file, None))
+
+        # If any cache files need to be updated, do that now
+        if to_update:
+            updated_cachefiles, conversion_errors = self._update_cachefiles(
+                project, to_update.values(), cls)
+            caches.extend(updated_cachefiles)
+        else:
+            conversion_errors = {}
+
+        return caches, conversion_errors
+
+    def _update_cachefiles(self, project, debug_files, cls):
+        rv = []
+        conversion_errors = {}
+
+        for debug_file in debug_files:
+            debug_id = debug_file.debug_id
+
+            # Find all the known bad files we could not convert last time. We
+            # use the debug identifier and file checksum to identify the source
+            # DIF for historic reasons (debug_file.id would do, too).
+            cache_key = 'scbe:%s:%s' % (debug_id, debug_file.file.checksum)
+            err = default_cache.get(cache_key)
+            if err is not None:
+                conversion_errors[debug_id] = err
+                continue
+
+            # Download the original debug symbol and convert the object file to
+            # a cache. This can either yield a cache object, an error or none of
+            # the above. THE FILE DOWNLOAD CAN TAKE SIGNIFICANT TIME.
+            with debug_file.file.getfile(as_tempfile=True) as tf:
+                file, cache, err = self._update_cachefile(debug_file, tf.name, cls)
+
+            # Store this conversion error so that we can skip subsequent
+            # conversions. There might be concurrent conversions running for the
+            # same debug file, however.
+            if err is not None:
+                default_cache.set(cache_key, err, CONVERSION_ERROR_TTL)
+                conversion_errors[debug_id] = err
+                continue
+
+            if file is not None or cache is not None:
+                rv.append((debug_id, file, cache))
+
+        return rv, conversion_errors
+
+    def _update_cachefile(self, debug_file, path, cls):
+        debug_id = debug_file.debug_id
+
+        # Skip silently if this cache cannot be computed from the given DIF
+        if not cls.computes_from(debug_file):
+            return None, None, None
+
+        # Locate the object inside the Archive. Since we have keyed debug
+        # files by debug_id, we expect a corresponding object. Otherwise, we
+        # fail silently, just like with missing symbols.
+        try:
+            archive = Archive.open(path)
+            obj = archive.get_object(debug_id=debug_id)
+            if obj is None:
+                return None, None, None
+
+            # Check features from the actual object file, if this is a legacy
+            # DIF where features have not been extracted yet.
+            if (debug_file.data or {}).get('features') is None:
+                if not set(cls.required_features) <= obj.features:
+                    return None, None, None
+
+            cache = cls.cache_cls.from_object(obj)
+        except SymbolicError as e:
+            if not isinstance(e, cls.ignored_errors):
+                logger.error('dsymfile.%s-build-error' % cls.cache_name,
+                             exc_info=True, extra=dict(debug_id=debug_id))
+
+            metrics.incr('%s.failed' % cls.cache_name, tags={
+                'error': e.__class__.__name__,
+            }, skip_internal=False)
+
+            return None, None, e.message
+
+        file = File.objects.create(name=debug_id, type='project.%s' % cls.cache_name)
+        file.putfile(cache.open_stream())
+
+        # Try to insert the new Cache into the database. This only fail if
+        # (1) another process has concurrently added the same sym cache, or if
+        # (2) the debug symbol was deleted, either due to a newer upload or via
+        # the API.
+        try:
+            with transaction.atomic():
+                return cls.objects.create(
+                    project=debug_file.project,
+                    cache_file=file,
+                    debug_file=debug_file,
+                    checksum=debug_file.file.checksum,
+                    version=cache.version,
+                ), cache, None
+        except IntegrityError:
+            file.delete()
+
+        # Check for a concurrently inserted cache and use that instead. This
+        # could have happened (1) due to a concurrent insert, or (2) a new
+        # upload that has already succeeded to compute a cache. The latter
+        # case is extremely unlikely.
+        cache_file = cls.objects \
+            .filter(project=debug_file.project, debug_file__debug_id=debug_id) \
+            .select_related('cache_file') \
+            .order_by('-id') \
+            .first()
+
+        if cache_file is not None:
+            return cache_file, None, None
+
+        # There was no new cache, indicating that the debug file has been
+        # replaced with a newer version. Another job will create the
+        # corresponding cache eventually. To prevent querying the database
+        # another time, simply use the in-memory cache for now:
+        return None, cache, None
+
+    def _load_cachefiles_via_fs(self, project, cachefiles, cls):
+        rv = {}
+        base = self.get_project_path(project)
+        cls_name = cls.__name__.lower()
+
+        for debug_id, model, cache in cachefiles:
+            # If we're given a cache instance, use that over accessing the file
+            # system or potentially even blob storage.
+            if cache is not None:
+                rv[debug_id] = cache
+                continue
+            elif model is None:
+                raise RuntimeError('missing %s file to load from fs' % cls_name)
+
+            # Try to locate a cached instance from the file system and bump the
+            # timestamp to indicate it is still being used. Otherwise, download
+            # from the blob store and place it in the cache folder.
+            cachefile_name = '%s_%s.%s' % (model.id, model.version, cls_name)
+            cachefile_path = os.path.join(base, cachefile_name)
+            try:
+                stat = os.stat(cachefile_path)
+            except OSError as e:
+                if e.errno != errno.ENOENT:
+                    raise
+                model.cache_file.save_to(cachefile_path)
+            else:
+                now = int(time.time())
+                if stat.st_ctime < now - ONE_DAY:
+                    os.utime(cachefile_path, (now, now))
+
+            rv[debug_id] = cls.open(cachefile_path)
+        return rv
+
     def clear_old_entries(self):
     def clear_old_entries(self):
         try:
         try:
             cache_folders = os.listdir(self.cache_path)
             cache_folders = os.listdir(self.cache_path)

+ 7 - 1
src/sentry/tasks/symcache_update.py

@@ -1,6 +1,7 @@
 from __future__ import absolute_import
 from __future__ import absolute_import
 
 
 from sentry.tasks.base import instrumented_task
 from sentry.tasks.base import instrumented_task
+from sentry.models import Project, ProjectDebugFile
 
 
 
 
 @instrumented_task(
 @instrumented_task(
@@ -9,4 +10,9 @@ from sentry.tasks.base import instrumented_task
     soft_time_limit=60,
     soft_time_limit=60,
 )
 )
 def symcache_update(project_id, debug_ids, **kwargs):
 def symcache_update(project_id, debug_ids, **kwargs):
-    pass  # Noop. TODO(ja): Remove once unused.
+    try:
+        project = Project.objects.get(id=project_id)
+    except Project.DoesNotExist:
+        return
+
+    ProjectDebugFile.difcache.update_caches(project, debug_ids)

+ 362 - 1
tests/sentry/models/test_debugfile.py

@@ -8,8 +8,11 @@ from six import BytesIO, text_type
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.core.urlresolvers import reverse
 from django.core.urlresolvers import reverse
 
 
+from symbolic import SYMCACHE_LATEST_VERSION
+
 from sentry.testutils import APITestCase, TestCase
 from sentry.testutils import APITestCase, TestCase
-from sentry.models import debugfile, File, ProjectDebugFile, DifMeta
+from sentry.models import debugfile, File, ProjectDebugFile, ProjectSymCacheFile, \
+    ProjectCfiCacheFile, DifMeta
 
 
 # This is obviously a freely generated UUID and not the checksum UUID.
 # This is obviously a freely generated UUID and not the checksum UUID.
 # This is permissible if users want to send different UUIDs
 # This is permissible if users want to send different UUIDs
@@ -29,11 +32,45 @@ class DebugFileTest(TestCase):
             features=['debug', 'unwind'],
             features=['debug', 'unwind'],
         )
         )
 
 
+        symcache_file = self.create_file(
+            name='baz.symcache',
+            size=42,
+            headers={'Content-Type': 'application/x-sentry-symcache'},
+            checksum='dc1e3f3e411979d336c3057cce64294f3420f93a',
+        )
+
+        symcache = ProjectSymCacheFile.objects.create(
+            project=self.project,
+            cache_file=symcache_file,
+            debug_file=dif,
+            checksum='dc1e3f3e411979d336c3057cce64294f3420f93a',
+            version=SYMCACHE_LATEST_VERSION,
+        )
+
+        cficache_file = self.create_file(
+            name='baz.cficache',
+            size=42,
+            headers={'Content-Type': 'application/x-sentry-cficache'},
+            checksum='dc1e3f3e411979d336c3057cce64294f3420f93a',
+        )
+
+        cficache = ProjectCfiCacheFile.objects.create(
+            project=self.project,
+            cache_file=cficache_file,
+            debug_file=dif,
+            checksum='dc1e3f3e411979d336c3057cce64294f3420f93a',
+            version=SYMCACHE_LATEST_VERSION,
+        )
+
         dif_id = dif.id
         dif_id = dif.id
         dif.delete()
         dif.delete()
 
 
         assert not ProjectDebugFile.objects.filter(id=dif_id).exists()
         assert not ProjectDebugFile.objects.filter(id=dif_id).exists()
         assert not File.objects.filter(id=dif.file.id).exists()
         assert not File.objects.filter(id=dif.file.id).exists()
+        assert not ProjectSymCacheFile.objects.filter(id=symcache.id).exists()
+        assert not File.objects.filter(id=symcache_file.id).exists()
+        assert not ProjectCfiCacheFile.objects.filter(id=cficache.id).exists()
+        assert not File.objects.filter(id=cficache_file.id).exists()
 
 
     def test_find_dif_by_debug_id(self):
     def test_find_dif_by_debug_id(self):
         debug_id1 = 'dfb8e43a-f242-3d73-a453-aeb6a777ef75'
         debug_id1 = 'dfb8e43a-f242-3d73-a453-aeb6a777ef75'
@@ -285,3 +322,327 @@ class DebugFilesClearTest(APITestCase):
 
 
         # But it's gone now
         # But it's gone now
         assert not os.path.isfile(difs[PROGUARD_UUID])
         assert not os.path.isfile(difs[PROGUARD_UUID])
+
+
+class SymCacheTest(TestCase):
+    def test_get_symcache(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        dif = self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+            features=['debug'],
+        )
+
+        file = self.create_file_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'v1.symcache'),
+            type='project.symcache'
+        )
+
+        ProjectSymCacheFile.objects.create(
+            project=self.project,
+            cache_file=file,
+            debug_file=dif,
+            checksum=dif.file.checksum,
+            # XXX: This version does not correspond to the actual file version,
+            # but is sufficient to avoid update behavior
+            version=SYMCACHE_LATEST_VERSION,
+        )
+
+        symcaches = ProjectDebugFile.difcache.get_symcaches(self.project, [debug_id])
+        assert debug_id in symcaches
+        assert symcaches[debug_id].debug_id == debug_id
+
+    def test_miss_symcache_without_feature(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+        )
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+            features=[],
+        )
+
+        # XXX: Explicit empty set denotes DIF without features. Since at least
+        # one file has declared features, get_symcaches will rather not use the
+        # other untagged file.
+        symcaches = ProjectDebugFile.difcache.get_symcaches(self.project, [debug_id])
+        assert debug_id not in symcaches
+
+    def test_create_symcache_without_feature(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+            file_format='macho',  # XXX: Needed for legacy compatibility check
+        )
+
+        symcaches = ProjectDebugFile.difcache.get_symcaches(self.project, [debug_id])
+        assert debug_id in symcaches
+        assert symcaches[debug_id].debug_id == debug_id
+
+    def test_create_symcache_with_feature(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+            features=['debug'],
+        )
+
+        symcaches = ProjectDebugFile.difcache.get_symcaches(self.project, [debug_id])
+        assert debug_id in symcaches
+        assert symcaches[debug_id].debug_id == debug_id
+
+    def test_skip_symcache_without_feature(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash'),
+            debug_id=debug_id,
+            file_format='macho',
+        )
+
+        symcaches = ProjectDebugFile.difcache.get_symcaches(self.project, [debug_id])
+        assert not symcaches
+
+    def test_update_symcache(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        dif = self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+        )
+
+        file = self.create_file_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'v1.symcache'),
+            headers={'Content-Type': 'application/x-sentry-symcache'},
+            type='project.symcache'
+        )
+
+        # Create an outdated SymCache to replace
+        old_cache = ProjectSymCacheFile.objects.create(
+            project=self.project,
+            cache_file=file,
+            debug_file=dif,
+            checksum=dif.file.checksum,
+            version=1,
+        )
+
+        symcaches = ProjectDebugFile.difcache.get_symcaches(self.project, [debug_id])
+        assert debug_id in symcaches
+        assert symcaches[debug_id].debug_id == debug_id
+        assert symcaches[debug_id].is_latest_version
+        assert not ProjectSymCacheFile.objects.filter(id=old_cache.id, version=1).exists()
+
+    def test_get_symcache_on_referenced(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        dif = self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+            features=['debug']
+        )
+
+        referenced_ids = []
+
+        def dif_referenced(dif):
+            referenced_ids.append(dif.id)
+
+        ProjectDebugFile.difcache.get_symcaches(
+            self.project,
+            [debug_id],
+            on_dif_referenced=dif_referenced
+        )
+        assert referenced_ids == [dif.id]
+
+    def test_symcache_conversion_error(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        self.create_dif_file(
+            debug_id=debug_id,
+            features=['debug']
+        )
+
+        symcaches, errors = ProjectDebugFile.difcache.get_symcaches(
+            self.project,
+            [debug_id],
+            with_conversion_errors=True
+        )
+        assert debug_id not in symcaches
+        assert debug_id in errors
+
+    def test_delete_symcache(self):
+        dif = self.create_dif_file(
+            debug_id='dfb8e43a-f242-3d73-a453-aeb6a777ef75-feedface',
+            features=['debug']
+        )
+
+        cache_file = self.create_file(
+            name='baz.symc',
+            size=42,
+            headers={'Content-Type': 'application/x-sentry-symcache'},
+            checksum='dc1e3f3e411979d336c3057cce64294f3420f93a',
+            type='project.symcache'
+        )
+
+        symcache = ProjectSymCacheFile.objects.create(
+            project=self.project,
+            cache_file=cache_file,
+            debug_file=dif,
+            checksum=dif.file.checksum,
+            version=SYMCACHE_LATEST_VERSION,
+        )
+
+        symcache.delete()
+        assert not File.objects.filter(id=cache_file.id).exists()
+        assert not ProjectSymCacheFile.objects.filter(id=symcache.id).exists()
+
+
+class CfiCacheTest(TestCase):
+    def test_get_cficache(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        dif = self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash'),
+            debug_id=debug_id,
+            features=['unwind'],
+        )
+
+        file = self.create_file_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'v1.cficache'),
+            type='project.cficache'
+        )
+
+        ProjectCfiCacheFile.objects.create(
+            project=self.project,
+            cache_file=file,
+            debug_file=dif,
+            checksum=dif.file.checksum,
+            # XXX: This version does not correspond to the actual file version,
+            # but is sufficient to avoid update behavior
+            version=SYMCACHE_LATEST_VERSION,
+        )
+
+        cficaches = ProjectDebugFile.difcache.get_cficaches(self.project, [debug_id])
+        assert debug_id in cficaches
+
+    def test_miss_cficache_without_feature(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash'),
+            debug_id=debug_id,
+            features=[],
+        )
+
+        # XXX: Explicit empty set denotes DIF without features. Since at least
+        # one file has declared features, get_cficaches will rather not use the
+        # other untagged file.
+        cficaches = ProjectDebugFile.difcache.get_cficaches(self.project, [debug_id])
+        assert debug_id not in cficaches
+
+    def test_create_cficache_with_feature(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash'),
+            debug_id=debug_id,
+            features=['unwind'],
+        )
+
+        cficaches = ProjectDebugFile.difcache.get_cficaches(self.project, [debug_id])
+        assert debug_id in cficaches
+
+    def test_skip_cficache_without_feature(self):
+        debug_id = '67e9247c-814e-392b-a027-dbde6748fcbf'
+        self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash.dsym'),
+            debug_id=debug_id,
+            file_format='macho',
+        )
+
+        symcaches = ProjectDebugFile.difcache.get_cficaches(self.project, [debug_id])
+        assert not symcaches
+
+    def test_update_cficache(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        dif = self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash'),
+            debug_id=debug_id,
+            features=['unwind'],
+        )
+
+        file = self.create_file_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'v1.symcache'),
+            headers={'Content-Type': 'application/x-sentry-cficache'},
+            type='project.cficache'
+        )
+
+        # Create an outdated CfiCache to replace
+        old_cache = ProjectCfiCacheFile.objects.create(
+            project=self.project,
+            cache_file=file,
+            debug_file=dif,
+            checksum=dif.file.checksum,
+            version=0,
+        )
+
+        cficaches = ProjectDebugFile.difcache.get_cficaches(self.project, [debug_id])
+        assert debug_id in cficaches
+        assert cficaches[debug_id].is_latest_version
+        assert not ProjectCfiCacheFile.objects.filter(id=old_cache.id, version=0).exists()
+
+    def test_get_cficache_on_referenced(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        dif = self.create_dif_from_path(
+            path=os.path.join(os.path.dirname(__file__), 'fixtures', 'crash'),
+            debug_id=debug_id,
+            features=['unwind'],
+        )
+
+        referenced_ids = []
+
+        def dif_referenced(dif):
+            referenced_ids.append(dif.id)
+
+        ProjectDebugFile.difcache.get_cficaches(
+            self.project,
+            [debug_id],
+            on_dif_referenced=dif_referenced
+        )
+        assert referenced_ids == [dif.id]
+
+    def test_cficache_conversion_error(self):
+        debug_id = '1ddb3423-950a-3646-b17b-d4360e6acfc9'
+        self.create_dif_file(
+            debug_id=debug_id,
+            features=['unwind'],
+        )
+
+        cficaches, errors = ProjectDebugFile.difcache.get_cficaches(
+            self.project,
+            [debug_id],
+            with_conversion_errors=True
+        )
+        assert debug_id not in cficaches
+        assert debug_id in errors
+
+    def test_delete_cficache(self):
+        dif = self.create_dif_file(
+            debug_id='dfb8e43a-f242-3d73-a453-aeb6a777ef75-feedface',
+            features=['unwind'],
+        )
+
+        cache_file = self.create_file(
+            name='baz.symc',
+            size=42,
+            headers={'Content-Type': 'application/x-sentry-cficache'},
+            checksum='dc1e3f3e411979d336c3057cce64294f3420f93a',
+            type='project.cficache'
+        )
+
+        cficache = ProjectCfiCacheFile.objects.create(
+            project=self.project,
+            cache_file=cache_file,
+            debug_file=dif,
+            checksum=dif.file.checksum,
+            version=SYMCACHE_LATEST_VERSION,
+        )
+
+        cficache.delete()
+        assert not File.objects.filter(id=cache_file.id).exists()
+        assert not ProjectCfiCacheFile.objects.filter(id=cficache.id).exists()