Skip to content

Commit 5a197e8

Browse files
authored
Make ReleaseFile TTI by tracking its last access time (#95867)
This brings the legacy `ReleaseFile`s in line with how debug files and `ArtifactBundle`s are being expired. My hope was to just be able to completely deprecate and delete all of `ReleaseFile`, but even though the usage of these is well below <1%, the deprecation isn’t really going anywhere. So might as well just give these a access-time-based TTI and move on. Most of the code is just a straight up copy of how this was done for debug files (#52257) **over 2 years ago**, except for the actual deletion job, which still needs to be implemented.
1 parent 0309fb7 commit 5a197e8

File tree

10 files changed

+181
-30
lines changed

10 files changed

+181
-30
lines changed

migrations_lockfile.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ preprod: 0012_installablepreprod
2727

2828
replays: 0006_add_bulk_delete_job
2929

30-
sentry: 0952_fix_span_item_event_type_alerts
30+
sentry: 0953_make_releasefiles_tti
3131

3232
social_auth: 0003_social_auth_json_field
3333

src/sentry/api/endpoints/artifact_lookup.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from __future__ import annotations
22

33
import logging
4-
from collections.abc import Iterable
54
from typing import NotRequired, TypedDict
65

76
from django.db.models.query import QuerySet
@@ -23,6 +22,7 @@
2322
MAX_BUNDLES_QUERY,
2423
query_artifact_bundles_containing_file,
2524
)
25+
from sentry.debug_files.release_files import maybe_renew_releasefiles, renew_releasefiles_by_id
2626
from sentry.lang.native.sources import get_internal_artifact_lookup_source_url
2727
from sentry.models.artifactbundle import NULL_STRING, ArtifactBundle
2828
from sentry.models.distribution import Distribution
@@ -160,14 +160,19 @@ def get(self, request: Request, project: Project) -> Response:
160160

161161
# If no `ArtifactBundle`s were found matching the file, we fall back to
162162
# looking up the file using the legacy `ReleaseFile` infrastructure.
163-
individual_files: Iterable[ReleaseFile] = []
163+
individual_files: list[ReleaseFile] = []
164164
if not artifact_bundles:
165165
release, dist = try_resolve_release_dist(project, release_name, dist_name)
166166
if release:
167167
metrics.incr("sourcemaps.lookup.release_file")
168-
for releasefile_id in get_legacy_release_bundles(release, dist):
168+
releasefile_ids = list(get_legacy_release_bundles(release, dist))
169+
for releasefile_id in releasefile_ids:
169170
all_bundles[f"release_file/{releasefile_id}"] = "release-old"
170-
individual_files = get_legacy_releasefile_by_file_url(release, dist, url)
171+
individual_files = list(get_legacy_releasefile_by_file_url(release, dist, url))
172+
173+
maybe_renew_releasefiles(individual_files)
174+
if releasefile_ids:
175+
renew_releasefiles_by_id(releasefile_ids)
171176

172177
# Then: Construct our response
173178
url_constructor = UrlConstructor(request, project)

src/sentry/api/endpoints/project_release_file_details.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from sentry.api.exceptions import ResourceDoesNotExist
1616
from sentry.api.serializers import serialize
1717
from sentry.api.serializers.models.release_file import decode_release_file_id
18+
from sentry.debug_files.release_files import maybe_renew_releasefiles
1819
from sentry.models.distribution import Distribution
1920
from sentry.models.release import Release
2021
from sentry.models.releasefile import ReleaseFile, delete_from_artifact_index, read_artifact_index
@@ -114,7 +115,9 @@ def _get_releasefile(release: Release, file_id: str, index_op=_get_from_index):
114115
raise ResourceDoesNotExist
115116
if isinstance(id, int):
116117
try:
117-
return ReleaseFile.public_objects.get(release_id=release.id, id=file_id)
118+
releasefile = ReleaseFile.public_objects.get(release_id=release.id, id=file_id)
119+
maybe_renew_releasefiles([releasefile])
120+
return releasefile
118121
except ReleaseFile.DoesNotExist:
119122
raise ResourceDoesNotExist
120123
else:

src/sentry/api/endpoints/project_release_files.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from sentry.api.paginator import ChainPaginator
1818
from sentry.api.serializers import serialize
1919
from sentry.constants import MAX_RELEASE_FILES_OFFSET
20+
from sentry.debug_files.release_files import maybe_renew_releasefiles
2021
from sentry.models.distribution import Distribution
2122
from sentry.models.files.file import File
2223
from sentry.models.release import Release
@@ -93,8 +94,11 @@ def get_releasefiles(self, request: Request, release, organization_id):
9394
source = ArtifactSource(dist, files, query, checksums)
9495
data_sources.append(source)
9596

96-
def on_results(r):
97-
return serialize(load_dist(r), request.user)
97+
def on_results(release_files: list[ReleaseFile]):
98+
# this should filter out all the "pseudo-ReleaseFile"s
99+
maybe_renew_releasefiles([rf for rf in release_files if rf.id])
100+
101+
return serialize(load_dist(release_files), request.user)
98102

99103
# NOTE: Returned release files are ordered by name within their block,
100104
# (i.e. per index file), but not overall

src/sentry/api/endpoints/source_map_debug_blue_thunder_edition.py

Lines changed: 32 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
from typing import Literal, TypedDict
22

33
import sentry_sdk
4-
from django.db.models import QuerySet
54
from django.utils.encoding import force_bytes, force_str
65
from drf_spectacular.utils import extend_schema
76
from packaging.version import Version
@@ -17,6 +16,7 @@
1716
from sentry.apidocs.constants import RESPONSE_FORBIDDEN, RESPONSE_NOT_FOUND, RESPONSE_UNAUTHORIZED
1817
from sentry.apidocs.parameters import EventParams, GlobalParams
1918
from sentry.apidocs.utils import inline_sentry_response_serializer
19+
from sentry.debug_files.release_files import maybe_renew_releasefiles
2020
from sentry.models.artifactbundle import (
2121
ArtifactBundle,
2222
ArtifactBundleArchive,
@@ -326,7 +326,7 @@ def __init__(self, abs_path: str, project: Project, release: Release, event):
326326
self.matching_source_map_name: str | None = None
327327

328328
# Cached db objects across operations
329-
self.artifact_index_release_files: QuerySet | list[ReleaseFile] | None = None
329+
self.artifact_index_release_files: list[ReleaseFile] | None = None
330330
self.dist_matched_artifact_index_release_file: ReleaseFile | None = None
331331

332332
self._find_source_file_in_basic_uploaded_files()
@@ -365,15 +365,18 @@ def _find_source_file_in_basic_uploaded_files(self) -> None:
365365
if self.source_file_lookup_result == "found":
366366
return
367367

368-
basic_release_source_files = ReleaseFile.objects.filter(
369-
organization_id=self.project.organization_id,
370-
release_id=self.release.id,
371-
name__in=self.matching_source_file_names,
372-
artifact_count=1, # Filter for un-zipped files
373-
).select_related("file")
368+
basic_release_source_files = list(
369+
ReleaseFile.objects.filter(
370+
organization_id=self.project.organization_id,
371+
release_id=self.release.id,
372+
name__in=self.matching_source_file_names,
373+
artifact_count=1, # Filter for un-zipped files
374+
).select_related("file")
375+
)
374376

375377
if len(basic_release_source_files) > 0:
376378
self.source_file_lookup_result = "wrong-dist"
379+
maybe_renew_releasefiles(basic_release_source_files)
377380

378381
for possible_release_file in basic_release_source_files:
379382
# Chck if dist matches
@@ -427,6 +430,7 @@ def _find_source_file_in_artifact_indexes(self):
427430
file__type="release.bundle",
428431
ident=archive_ident,
429432
)
433+
maybe_renew_releasefiles([archive_file])
430434
with ReleaseArchive(archive_file.file.getfile()) as archive:
431435
source_file, headers = archive.get_file_by_url(
432436
self.found_source_file_name
@@ -506,15 +510,18 @@ def _find_source_map_in_basic_uploaded_files(self, matching_source_map_name: str
506510
if self.source_map_lookup_result == "found":
507511
return
508512

509-
basic_release_source_map_files = ReleaseFile.objects.filter(
510-
organization_id=self.project.organization_id,
511-
release_id=self.release.id,
512-
name=matching_source_map_name,
513-
artifact_count=1, # Filter for un-zipped files
514-
).select_related("file")
513+
basic_release_source_map_files = list(
514+
ReleaseFile.objects.filter(
515+
organization_id=self.project.organization_id,
516+
release_id=self.release.id,
517+
name=matching_source_map_name,
518+
artifact_count=1, # Filter for un-zipped files
519+
).select_related("file")
520+
)
515521

516522
if len(basic_release_source_map_files) > 0:
517523
self.source_map_lookup_result = "wrong-dist"
524+
maybe_renew_releasefiles(basic_release_source_map_files)
518525
for basic_release_source_map_file in basic_release_source_map_files:
519526
if basic_release_source_map_file.ident == ReleaseFile.get_ident(
520527
basic_release_source_map_file.name, self.event.dist
@@ -561,18 +568,19 @@ def _find_source_map_in_artifact_bundles(self, matching_source_map_name: str):
561568
self.source_map_lookup_result = "found"
562569
return
563570

564-
def _get_artifact_index_release_files(self):
571+
def _get_artifact_index_release_files(self) -> list[ReleaseFile]:
565572
# Cache result
566573
if self.artifact_index_release_files is not None:
567574
return self.artifact_index_release_files
568575

569-
self.artifact_index_release_files = ReleaseFile.objects.filter(
570-
organization_id=self.project.organization_id,
571-
release_id=self.release.id,
572-
file__type="release.artifact-index",
573-
).select_related("file")[
574-
:ARTIFACT_INDEX_LOOKUP_LIMIT
575-
] # limit by something sane in case people have a large number of dists for the same release
576+
self.artifact_index_release_files = list(
577+
ReleaseFile.objects.filter(
578+
organization_id=self.project.organization_id,
579+
release_id=self.release.id,
580+
file__type="release.artifact-index",
581+
).select_related("file")[:ARTIFACT_INDEX_LOOKUP_LIMIT]
582+
) # limit by something sane in case people have a large number of dists for the same release
583+
maybe_renew_releasefiles(self.artifact_index_release_files)
576584

577585
return self.artifact_index_release_files
578586

@@ -591,6 +599,8 @@ def _get_dist_matched_artifact_index_release_file(self):
591599
.select_related("file")
592600
.first()
593601
)
602+
if self.dist_matched_artifact_index_release_file:
603+
maybe_renew_releasefiles([self.dist_matched_artifact_index_release_file])
594604

595605
return self.dist_matched_artifact_index_release_file
596606

src/sentry/api/helpers/source_map_helper.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
from sentry import eventstore
1010
from sentry.api.endpoints.project_release_files import ArtifactSource
11+
from sentry.debug_files.release_files import maybe_renew_releasefiles
1112
from sentry.eventstore.models import BaseEvent
1213
from sentry.interfaces.exception import Exception as ExceptionInterface
1314
from sentry.interfaces.stacktrace import Frame
@@ -293,6 +294,7 @@ def _get_releasefiles(release: Release, organization_id: int) -> list[ReleaseFil
293294
file_list = file_list.select_related("file").order_by("name")
294295

295296
data_sources.extend(list(file_list.order_by("name")))
297+
maybe_renew_releasefiles(data_sources)
296298

297299
dists = Distribution.objects.filter(organization_id=organization_id, release=release)
298300
for dist in list(dists) + [None]:
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
from __future__ import annotations
2+
3+
from datetime import timedelta
4+
5+
from django.db import router
6+
from django.utils import timezone
7+
8+
from sentry.models.releasefile import ReleaseFile
9+
from sentry.utils import metrics
10+
from sentry.utils.db import atomic_transaction
11+
12+
# Number of days that determine whether a release file is ready for being renewed.
13+
AVAILABLE_FOR_RENEWAL_DAYS = 30
14+
15+
16+
def maybe_renew_releasefiles(releasefiles: list[ReleaseFile]):
17+
# We take a snapshot in time that MUST be consistent across all updates.
18+
now = timezone.now()
19+
# We compute the threshold used to determine whether we want to renew the specific bundle.
20+
threshold_date = now - timedelta(days=AVAILABLE_FOR_RENEWAL_DAYS)
21+
22+
# We first check if any file needs renewal, before going to the database.
23+
needs_bump = [rf.id for rf in releasefiles if rf.date_accessed <= threshold_date]
24+
if not needs_bump:
25+
return
26+
27+
renew_releasefiles_by_id(needs_bump)
28+
29+
30+
def renew_releasefiles_by_id(releasefile_ids: list[int]):
31+
now = timezone.now()
32+
threshold_date = now - timedelta(days=AVAILABLE_FOR_RENEWAL_DAYS)
33+
34+
with metrics.timer("release_files_renewal"):
35+
with atomic_transaction(using=(router.db_for_write(ReleaseFile),)):
36+
updated_rows_count = ReleaseFile.objects.filter(
37+
id__in=releasefile_ids, date_accessed__lte=threshold_date
38+
).update(date_accessed=now)
39+
if updated_rows_count > 0:
40+
metrics.incr("release_files_renewal.were_renewed", updated_rows_count)
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
# Generated by Django 5.2.1 on 2025-07-18 10:28
2+
3+
import django.db.models.functions.datetime
4+
import django.utils.timezone
5+
from django.db import migrations, models
6+
7+
from sentry.new_migrations.migrations import CheckedMigration
8+
9+
10+
class Migration(CheckedMigration):
11+
# This flag is used to mark that a migration shouldn't be automatically run in production.
12+
# This should only be used for operations where it's safe to run the migration after your
13+
# code has deployed. So this should not be used for most operations that alter the schema
14+
# of a table.
15+
# Here are some things that make sense to mark as post deployment:
16+
# - Large data migrations. Typically we want these to be run manually so that they can be
17+
# monitored and not block the deploy for a long period of time while they run.
18+
# - Adding indexes to large tables. Since this can take a long time, we'd generally prefer to
19+
# run this outside deployments so that we don't block them. Note that while adding an index
20+
# is a schema change, it's completely safe to run the operation after the code has deployed.
21+
# Once deployed, run these manually via: https://develop.sentry.dev/database-migrations/#migration-deployment
22+
23+
is_post_deployment = False
24+
25+
dependencies = [
26+
("sentry", "0952_fix_span_item_event_type_alerts"),
27+
]
28+
29+
operations = [
30+
migrations.AddField(
31+
model_name="releasefile",
32+
name="date_accessed",
33+
field=models.DateTimeField(
34+
db_default=django.db.models.functions.datetime.Now(),
35+
default=django.utils.timezone.now,
36+
),
37+
),
38+
]

src/sentry/models/releasefile.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
import sentry_sdk
1313
from django.db import models, router
14+
from django.db.models.functions import Now
15+
from django.utils import timezone
1416

1517
from sentry.backup.scopes import RelocationScope
1618
from sentry.db.models import (
@@ -74,6 +76,8 @@ class ReleaseFile(Model):
7476
name = models.TextField()
7577
dist_id = BoundedBigIntegerField(null=True, db_index=True)
7678

79+
date_accessed = models.DateTimeField(default=timezone.now, db_default=Now())
80+
7781
#: For classic file uploads, this field is 1.
7882
#: For release archives, this field is 0.
7983
#: For artifact indexes, this field is the number of artifacts contained
@@ -111,6 +115,7 @@ def update(self, *args, **kwargs):
111115
0
112116
]
113117
kwargs["ident"] = self.ident = type(self).get_ident(kwargs["name"], dist_name)
118+
kwargs["date_accessed"] = timezone.now()
114119
return super().update(*args, **kwargs)
115120

116121
@classmethod

tests/sentry/api/endpoints/test_project_artifact_lookup.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -596,6 +596,50 @@ def test_renewal_with_url(self):
596596
== expected_date_added
597597
)
598598

599+
def test_renewal_of_releasefiles(self):
600+
old_timestamp = datetime.now(tz=timezone.utc) - timedelta(days=45)
601+
602+
file_headers = {"Sourcemap": "application.js.map"}
603+
file = make_file("application.js", b"wat", "release.file", file_headers)
604+
releasefile = ReleaseFile.objects.create(
605+
organization_id=self.project.organization_id,
606+
release_id=self.release.id,
607+
file=file,
608+
name="http://example.com/application.js",
609+
date_accessed=old_timestamp,
610+
)
611+
612+
archive1, archive1_file = self.create_archive(
613+
fields={},
614+
files={
615+
"foo": "foo1",
616+
"bar": "bar1",
617+
},
618+
)
619+
archive1.date_accessed = old_timestamp
620+
archive1.save()
621+
622+
self.login_as(user=self.user)
623+
624+
url = reverse(
625+
"sentry-api-0-project-artifact-lookup",
626+
kwargs={
627+
"organization_id_or_slug": self.project.organization.slug,
628+
"project_id_or_slug": self.project.slug,
629+
},
630+
)
631+
632+
response = self.client.get(
633+
f"{url}?release={self.release.version}&url=application.js"
634+
).json()
635+
636+
# the lookup finds both, as the bundle is resolved only by the release
637+
assert len(response) == 2
638+
assert response[0]["type"] == "file"
639+
assert response[1]["type"] == "bundle"
640+
assert ReleaseFile.objects.get(id=releasefile.id).date_accessed > old_timestamp
641+
assert ReleaseFile.objects.get(id=archive1.id).date_accessed > old_timestamp
642+
599643
def test_access_control(self):
600644
# release file
601645
file_a = make_file("application.js", b"wat", "release.file", {})

0 commit comments

Comments
 (0)