From b3f69a4b5aa3a28941595deedf4526365f8c59dd Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:25:57 +0200 Subject: [PATCH 1/2] feat(debug-files): exclusive Objectstore write for legacy DIF uploads Under organizations:objectstore-debugfiles-exclusive-write, route zip/dSYM uploads and ProGuard clone reuploads through create_objectstore_dif_from_id so no File row is created. --- src/sentry/api/endpoints/debug_files.py | 24 ++++++++- src/sentry/models/debugfile.py | 34 ++++++++----- .../sentry/api/endpoints/test_debug_files.py | 27 ++++++++++ .../sentry/api/endpoints/test_dif_assemble.py | 49 +++++++++++++++++++ 4 files changed, 122 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index 720fb2d07962..2f06a944309a 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -841,7 +841,29 @@ def _clone_proguard_debug_file_for_reupload( } meta = build_proguard_reupload_dif_meta(debug_file, requested_debug_id) - if debug_file.file is not None and not debug_file.uses_objectstore_for_read(): + exclusive = features.has( + "organizations:objectstore-debugfiles-exclusive-write", project.organization + ) + + if exclusive: + # Exclusive writes always produce an Objectstore-only clone, regardless + # of whether the source is File-backed, dual-written, or Objectstore-only. + checksum = debug_file.get_checksum() + file_size = debug_file.get_file_size() + if debug_file.file is not None and not debug_file.uses_objectstore_for_read(): + source_fileobj = debug_file.file.getfile() + else: + source_fileobj = debug_file.get_file() + try: + with tempfile.TemporaryFile() as tmp: + shutil.copyfileobj(source_fileobj, tmp) + tmp.seek(0) + dif, created = create_objectstore_dif_from_id( + project, meta, tmp, checksum, file_size + ) + finally: + source_fileobj.close() + elif debug_file.file is not None and not debug_file.uses_objectstore_for_read(): # Legacy File-backed source (and dual-written source when Objectstore # reads are disabled): reuse the existing File row under a new debug ID. dif, created = create_dif_from_id(project, meta, file=debug_file.file) diff --git a/src/sentry/models/debugfile.py b/src/sentry/models/debugfile.py index 539b5285c587..87d3b4782eee 100644 --- a/src/sentry/models/debugfile.py +++ b/src/sentry/models/debugfile.py @@ -613,6 +613,20 @@ def create_objectstore_dif_from_id( return dif, True +def _checksum_and_size(fileobj: IO[bytes]) -> tuple[str, int]: + """Returns ``(sha1_hex, size)`` for ``fileobj`` and rewinds it to the start.""" + file_size = 0 + h = hashlib.sha1() + while True: + chunk = fileobj.read(16384) + if not chunk: + break + h.update(chunk) + file_size += len(chunk) + fileobj.seek(0, 0) + return h.hexdigest(), file_size + + def create_dif_from_id( project: Project, meta: DifMeta, @@ -641,16 +655,7 @@ def create_dif_from_id( checksum = file.checksum assert checksum is not None elif fileobj is not None: - file_size = 0 - h = hashlib.sha1() - while True: - chunk = fileobj.read(16384) - if not chunk: - break - h.update(chunk) - file_size += len(chunk) - checksum = h.hexdigest() - fileobj.seek(0, 0) + checksum, file_size = _checksum_and_size(fileobj) else: raise RuntimeError("missing file object") @@ -1018,10 +1023,17 @@ def create_debug_file_from_dif( """Create a ProjectDebugFile from a dif (Debug Information File) and return an array of created objects. """ + exclusive = features.has( + "organizations:objectstore-debugfiles-exclusive-write", project.organization + ) rv = [] for meta in to_create: with open(meta.path, "rb") as f: - dif, created = create_dif_from_id(project, meta, fileobj=f) + if exclusive: + checksum, file_size = _checksum_and_size(f) + dif, created = create_objectstore_dif_from_id(project, meta, f, checksum, file_size) + else: + dif, created = create_dif_from_id(project, meta, fileobj=f) if created: rv.append(dif) return rv diff --git a/tests/sentry/api/endpoints/test_debug_files.py b/tests/sentry/api/endpoints/test_debug_files.py index bbe192e03043..a99b0ce68533 100644 --- a/tests/sentry/api/endpoints/test_debug_files.py +++ b/tests/sentry/api/endpoints/test_debug_files.py @@ -341,6 +341,33 @@ def test_project_debug_files_role_overrides_organization(self) -> None: self._assert_successful_download(response, PROGUARD_SOURCE) +@requires_objectstore +class DebugFileObjectstoreExclusiveUploadTest(DebugFilesTestCases): + """Zip/dSYM uploads under organizations:objectstore-debugfiles-exclusive-write.""" + + def test_exclusive_write_zip_upload_is_objectstore_only(self) -> None: + with self.feature( + { + "organizations:objectstore-debugfiles-exclusive-write": True, + "organizations:objectstore-debugfiles-write": False, + "organizations:objectstore-debugfiles-read": True, + } + ): + response = self._upload_proguard(self.url, PROGUARD_UUID) + + assert response.status_code == 201, response.content + assert len(response.data) == 1 + assert response.data[0]["uuid"] == PROGUARD_UUID + assert response.data[0]["sha1"] == "e6d3c5185dac63eddfdc1a5edfffa32d46103b44" + + dif = ProjectDebugFile.objects.get(project_id=self.project.id, debug_id=PROGUARD_UUID) + assert dif.file_id is None + assert dif.storage_path is not None + assert dif.content_type == "text/x-proguard+plain" + assert dif.get_file().read() == PROGUARD_SOURCE + assert not File.objects.filter(type="project.dif").exists() + + @requires_objectstore class DebugFileObjectstoreRedirectTest(DebugFilesTestCases): """Explicit coverage of both redirect branches for Objectstore-backed debug files.""" diff --git a/tests/sentry/api/endpoints/test_dif_assemble.py b/tests/sentry/api/endpoints/test_dif_assemble.py index 1bf931bd66f1..288dcb5bb550 100644 --- a/tests/sentry/api/endpoints/test_dif_assemble.py +++ b/tests/sentry/api/endpoints/test_dif_assemble.py @@ -540,3 +540,52 @@ def test_clone_dual_written_source_to_file(self) -> None: assert second_dif.file_id is not None assert second_dif.storage_path is None assert second_dif.get_file().read() == file_contents + + def test_clone_file_backed_source_to_objectstore_exclusive(self) -> None: + """A file-backed source is cloned under exclusive-write as Objectstore-only.""" + + file_contents = b"proguard mapping" + checksum = sha1(file_contents).hexdigest() + blob = FileBlob.from_file_with_organization(ContentFile(file_contents), self.organization) + chunks = [blob.checksum] + + with self.feature( + { + "organizations:objectstore-debugfiles-exclusive-write": False, + "organizations:objectstore-debugfiles-write": False, + } + ): + self._assemble_source(checksum, chunks) + + first_dif = ProjectDebugFile.objects.get( + project_id=self.project.id, + debug_id="00000000-0000-0000-0000-000000000000", + ) + assert first_dif.file_id is not None + assert first_dif.storage_path is None + + with self.feature( + { + "organizations:objectstore-debugfiles-exclusive-write": True, + "organizations:objectstore-debugfiles-write": False, + "organizations:objectstore-debugfiles-read": True, + } + ): + response = self._clone_request(checksum, chunks) + + assert response.status_code == 200, response.content + assert response.data[checksum]["state"] == ChunkFileState.OK + assert response.data[checksum]["dif"]["uuid"] == "11111111-1111-1111-1111-111111111111" + + second_dif = ProjectDebugFile.objects.get( + project_id=self.project.id, + debug_id="11111111-1111-1111-1111-111111111111", + ) + # Source stays file-backed; clone is Objectstore-only. + first_dif.refresh_from_db() + assert first_dif.file_id is not None + assert first_dif.storage_path is None + assert second_dif.file_id is None + assert second_dif.storage_path is not None + assert second_dif.get_file().read() == file_contents + assert File.objects.filter(type="project.dif", checksum=checksum).count() == 1 From 6cfd10d8f77528bc73b2ed041703cb271ab72e9f Mon Sep 17 00:00:00 2001 From: lcian <17258265+lcian@users.noreply.github.com> Date: Thu, 30 Jul 2026 12:32:21 +0200 Subject: [PATCH 2/2] improve --- src/sentry/api/endpoints/debug_files.py | 10 ++-------- src/sentry/models/debugfile.py | 7 +++---- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/src/sentry/api/endpoints/debug_files.py b/src/sentry/api/endpoints/debug_files.py index 2f06a944309a..c905917413c3 100644 --- a/src/sentry/api/endpoints/debug_files.py +++ b/src/sentry/api/endpoints/debug_files.py @@ -841,19 +841,13 @@ def _clone_proguard_debug_file_for_reupload( } meta = build_proguard_reupload_dif_meta(debug_file, requested_debug_id) - exclusive = features.has( - "organizations:objectstore-debugfiles-exclusive-write", project.organization - ) - if exclusive: + if features.has("organizations:objectstore-debugfiles-exclusive-write", project.organization): # Exclusive writes always produce an Objectstore-only clone, regardless # of whether the source is File-backed, dual-written, or Objectstore-only. checksum = debug_file.get_checksum() file_size = debug_file.get_file_size() - if debug_file.file is not None and not debug_file.uses_objectstore_for_read(): - source_fileobj = debug_file.file.getfile() - else: - source_fileobj = debug_file.get_file() + source_fileobj = debug_file.get_file() try: with tempfile.TemporaryFile() as tmp: shutil.copyfileobj(source_fileobj, tmp) diff --git a/src/sentry/models/debugfile.py b/src/sentry/models/debugfile.py index 87d3b4782eee..162d42ad5675 100644 --- a/src/sentry/models/debugfile.py +++ b/src/sentry/models/debugfile.py @@ -1023,13 +1023,12 @@ def create_debug_file_from_dif( """Create a ProjectDebugFile from a dif (Debug Information File) and return an array of created objects. """ - exclusive = features.has( - "organizations:objectstore-debugfiles-exclusive-write", project.organization - ) rv = [] for meta in to_create: with open(meta.path, "rb") as f: - if exclusive: + if features.has( + "organizations:objectstore-debugfiles-exclusive-write", project.organization + ): checksum, file_size = _checksum_and_size(f) dif, created = create_objectstore_dif_from_id(project, meta, f, checksum, file_size) else: