Skip to content

Builds: delete old build objects#12800

Open
stsewd wants to merge 14 commits intomainfrom
delete-old-build-objects
Open

Builds: delete old build objects#12800
stsewd wants to merge 14 commits intomainfrom
delete-old-build-objects

Conversation

@stsewd
Copy link
Member

@stsewd stsewd commented Feb 19, 2026

  • Archiving builds was refactored, so I could re-use how to get the build path.
  • Since we want to keep x builds for each version, we need to iterate over each project or version. Doing that will take a long time for the task to run, and trying to filter by an aggregation will result in a slow query that goes over the 2-second threshold we have. So I'm processing a slice of projects over each run, I'm using the cache to keep track of the last slice.
  • Commands from build objects are also now deleted from storage.
  • Had to change the delete_in_batches functions to support a slice instead of just a limit, so I could use them with the new code, since django doesn't allow any filtering or deletion on a sliced queryset.
  • Tested the querysets in production (.org), and they are fast, but hard to tell how fast they would be when actually deleting stuff. I did a count/select only.

Community

Around 500 projects make up 30% of the builds in the database.

If we are very conservative, we can delete:

delete_old_build_objects(days=360 * 3, keep_recent=250)
Found 5513191 builds to delete (~22%).

If we are a little bit more aggressive, we can delete:

delete_old_build_objects(days=360, keep_recent=100)
Found 12488385 builds to delete (~50%).

Business

Around 90 projects make up 56% of the builds in the database.

If we are very conservative, we can delete:

delete_old_build_objects(days=360 * 3, keep_recent=250)
Found 621459 builds to delete (~19%).

If we are a little bit more aggressive, we can delete:

delete_old_build_objects(days=360, keep_recent=100)
Found 1847314 builds to delete (~56%).

Full analysis at https://readthedocs.slack.com/archives/G81T0N8S3/p1771531958918869?thread_ts=1771531854.785459&cid=G81T0N8S3

Closes #12712

Copy link
Member

@humitos humitos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is going in a good direction 👍🏼 . We should change the logic to keep the N builds for each version as we discussed in the meeting, tho.

If we are very conservative

Starting conservative is fine. There is no reason to rush on this and delete all the objects at once.

@read-the-docs-community
Copy link

read-the-docs-community bot commented Feb 25, 2026

Documentation build overview

📚 dev | 🛠️ Build #31631151 | 📁 Comparing a681538 against latest (d7b9779)


🔍 Preview build

No files changed.

@stsewd
Copy link
Member Author

stsewd commented Feb 26, 2026

@copilot write tests for the delete_old_build_objects function

Copy link
Contributor

Copilot AI commented Feb 26, 2026

@stsewd I've opened a new pull request, #12820, to work on those changes. Once the pull request is ready, I'll request review from you.

Copilot AI and others added 3 commits February 26, 2026 19:12
…n `_delete_builds` (#12820)

- [x] Write tests for `delete_old_build_objects` function
- [x] Fix `KeyError` in `_delete_builds` when no builds are deleted
- [x] Use `assert foo == bar` syntax instead of `self.assertEqual` in
`TestDeleteOldBuildObjects`

<!-- START COPILOT CODING AGENT TIPS -->
---

🔒 GitHub Advanced Security automatically protects Copilot coding agent
pull requests. You can protect all pull requests by enabling Advanced
Security for your repositories. [Learn more about Advanced
Security.](https://gh.io/cca-advanced-security)

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: stsewd <4975310+stsewd@users.noreply.github.com>
@stsewd stsewd marked this pull request as ready for review February 27, 2026 01:49
@stsewd stsewd requested a review from a team as a code owner February 27, 2026 01:49
@stsewd stsewd requested a review from humitos February 27, 2026 01:49
Copy link
Member

@humitos humitos left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking good. However, I think there are a few things we should do before moving forward and merging. There are some performance issues here and also a tweak needed in the deletion of objects formula.

We should be cautious here since we will be deleting a lot of objects. Another person should review this as well.

Comment on lines +621 to +626
builds_to_delete = version.builds.filter(
state__in=BUILD_FINAL_STATES,
date__lt=cutoff_date,
).order_by("-date")
limit -= _delete_builds(builds_to_delete, start=keep_recent, end=keep_recent + limit)
if limit <= 0:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are not taking the slice here? I looks weird that we are adding all the builds to builds_to_delete but we are filtering them again and not deleting them all.

Suggested change
builds_to_delete = version.builds.filter(
state__in=BUILD_FINAL_STATES,
date__lt=cutoff_date,
).order_by("-date")
limit -= _delete_builds(builds_to_delete, start=keep_recent, end=keep_recent + limit)
if limit <= 0:
builds_to_delete = version.builds.filter(
state__in=BUILD_FINAL_STATES,
date__lt=cutoff_date,
).order_by("-date")[keep_recent:keep_recent + limit]
limit -= _delete_builds(builds_to_delete)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I prefer if we perform the slice here since it will be clearer and avoid confusions.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deleting in batches doesn't work over a sliced queryset. It's explained in the PR description.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, but this is pretty confusing to me. We are calling _delete_builds(builds_to_delete) but the result is that those builds are not deleted? They are filtered instead and just a few of them are deleted? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, we are calling _delete_builds(queryset, start, end), that seems explicit?

Comment on lines +629 to +636
# Delete builds that are not associated with any version,
# keeping the most recent `keep_recent` builds per project.
builds_to_delete = project.builds.filter(
version=None, state__in=BUILD_FINAL_STATES, date__lt=cutoff_date
).order_by("-date")
limit -= _delete_builds(builds_to_delete, start=keep_recent, end=keep_recent + limit)
if limit <= 0:
return
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the builds are not associated to any version, this is because the version was deleted. We shouldn't keep only keep_recent for all of those builds here, but keep_recent builds per build.version_slug instead to be consistent with the formula.

Copy link
Member Author

@stsewd stsewd Mar 2, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it worth it complicating this more? If the version is deleted, the build is never referenced by any version. We could keep 2x or 3x the builds here if we want to keep more builds, but honestly I think we are fine here.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are we saving this builds for if they are not reference by any version?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So their builds page wouldn't immediately 404, and users had a history of previous builds. I'll be fine deleting builds when a version is deleted.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think we either want:

  1. delete these build objects, OR
  2. apply the same formula we are applying to the other builds

but I wouldn't create another logic for this particular use case.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but I wouldn't create another logic for this particular use case.

It's simpler and faster to apply a global limit than grouping the query for versions and then delete builds for each one of them...

@stsewd stsewd requested a review from humitos March 2, 2026 21:26
"""
for batch in batched(paths, 1000):
objects = [{"Key": path} for path in batch]
self.bucket.delete_objects(Delete={"Objects": objects, "Quiet": True})
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you know what Quiet means. I read:

Quiet (boolean) –
Element to enable quiet mode for the request. When you add this element, you must set its value to true.

but I don't understand it. Would this call raise an exception if it fails?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It reports the result of each path that was deleted, if it failed or not. We aren't doing anything with the result, so we don't need it.

Comment on lines +82 to +90
def delete_paths(self, paths):
"""
Delete multiple paths from storage.

This is a convenience method to delete multiple paths at once, which can be more efficient
for some storage backends that support batch deletion (eg. S3).
"""
for path in paths:
self.delete(path)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand why we need this method here, 🤔 ?

I saw that you created RTDS3Boto3Storage.delete_paths below that looks 👍🏼 -- why do we need to override it here?

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is needed for testing, we use a file system backend there. I actually want to refactor how we inherit these classes, currently not all storage backends inherit these classes.

@humitos
Copy link
Member

humitos commented Mar 3, 2026

We should be cautious here since we will be deleting a lot of objects. Another person should review this as well.

Pinging @agjohnson here so he can perform another review. The pattern to delete objects has been increasing its complexity in the last weeks and I don't understand it anymore. We have a lot going on here in multiple functions:

  • delete_old_build_objects
  • delete_in_batches
  • _delete_builds
  • raw_delete_in_batches

Why do we need 4 functions to perform the deletion and what does each of them? I can't explain them by myself.

@agjohnson If you don't want to review all the PR, these are my main concerns:

  1. multiple *delete_* functions that all seem to do the same but I don't understand each of them (above comment)
  2. call a function to delete builds that don't delete them all
  3. what to do with builds not associated to a version
  4. perform path calculation at db level to speed things up

@humitos humitos requested a review from agjohnson March 3, 2026 10:27
@stsewd
Copy link
Member Author

stsewd commented Mar 3, 2026

Why do we need 4 functions to perform the deletion and what does each of them? I can't explain them by myself.

  • delete_old_build_objects: that's the task, it doesn't have anything to do with deletion in general.
  • _delete_builds: that's a helper to allow deleting builds from the DB and storage.
  • delete_in_batches/raw_delete_in_batches: these are the main functions to delete objecst in batches, one uses a normal deletion, the other one a raw delete. The exact use case is described in their docstring.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Builds: delete old build objects

3 participants