Loading
Delete generic packages not downloaded in N days
What does this MR do and why?
- Adds a package cleanup policy rule: delete generic packages not downloaded in N days. N is any whole number, 1 or more.
nullturns the rule off (default). - Teams use the generic package registry for short-lived data, like build caches. Today the only way to expire it is one DELETE API call per package. One customer sends about 40,000 of these calls a night.
- This MR adds the rule through the API only. Set it with the GraphQL
updatePackagesCleanupPolicymutation and a newdeleteGenericPackagesNotDownloadedInDaysargument. ThePackagesCleanupPolicytype exposes the same field. Both are marked experiment. There is no UI in this MR. - The rule sits behind the
packages_cleanup_delete_generic_not_downloadedfeature flag (gitlab_com_derisk, default off). With the flag off, the argument is ignored and the rule does not run.
How it works:
- Which packages: generic packages whose last download date, or creation date if never downloaded, is older than N days. Uses the existing
packages_packages.last_downloaded_atcolumn. - Skipped packages: packages still processing or already pending destruction, packages that match a package protection rule with a minimum access level for delete, and all other package formats.
- Deletion: marks the package's files, then the package itself, as
pending_destruction. The existingPackages::CleanupPackageFileWorkerdeletes the files and removes the package once its last file is gone. - Scheduling: runs inside the existing
Packages::Cleanup::ExecutePolicyWorkerevery 12 hours and shares its 250-second time budget. It runs before the duplicated-files rule, so a project whose duplicate cleanup keeps timing out still gets this rule.
Commits are split: migration, implementation and specs, and docs.
References
Screenshots or screen recordings
Not applicable. This MR only changes the API and a background job. It has no UI changes.
How to set up and validate locally
- Enable the flag in a Rails console:
Feature.enable(:packages_cleanup_delete_generic_not_downloaded) - Upload a generic package with the API. Then age it in the Rails console:
Packages::Package.find(<package_id>).update_columns(created_at: 40.days.ago, last_downloaded_at: nil) - Set the rule in GraphiQL (
/-/graphql-explorer):mutation { updatePackagesCleanupPolicy(input: { projectPath: "<your-project>", deleteGenericPackagesNotDownloadedInDays: 30 }) { packagesCleanupPolicy { deleteGenericPackagesNotDownloadedInDays } errors } } - Make the policy due, then run it:
policy = Project.find_by_full_path('<your-project>').packages_cleanup_policy policy.update_column(:next_run_at, 1.minute.ago) Packages::Cleanup::ExecutePolicyService.new(policy).execute - Check the package status. It should be
pending_destruction:Packages::Package.find(<package_id>).status
Database review
- Migrations: a regular migration adds a nullable
integercolumndelete_generic_packages_not_downloaded_in_daystopackages_cleanup_policies(a small table), with a check constraint that it is from 1 to 3650. A post-deploy migration replaces the partial index the policy scheduler uses (idx_enabled_pkgs_cleanup_policies_on_next_run_at_project_id) with one whose predicate also covers the new column (idx_active_pkgs_cleanup_policies_on_next_run_at_project_id), matching the updatedPackages::Cleanup::Policy.activescope. - New queries: the scheduler query now filters
keep_n_duplicated_package_files <> 'all' OR delete_generic_packages_not_downloaded_in_days IS NOT NULL. The new rule batches a project's packages one status at a time on the existingindex_packages_packages_on_project_id_and_status_and_idindex (500 per batch). Each batch then filters to generic packages,COALESCE(last_downloaded_at, created_at) < cutoff, and aNOT EXISTSagainstpackages_protection_rules. - No new index on
packages_packages: the table is on theMigration/PreventIndexCreationlist, so the rule avoids needing one. The batch boundary query is an index-only scan.
Migration output
# down (both)
== 20260924180100 ReplacePackagesCleanupPoliciesNextRunAtIndex: reverting =====
-- transaction_open?(nil)
-> 0.0000s
-- view_exists?(:postgres_partitions)
-> 0.0631s
-- index_exists?(:packages_cleanup_policies, [:next_run_at, :project_id], {:where=>"keep_n_duplicated_package_files <> 'all'", :name=>"idx_enabled_pkgs_cleanup_policies_on_next_run_at_project_id", :algorithm=>:concurrently})
-> 0.0054s
-- execute("SET statement_timeout TO 0")
-> 0.0016s
-- add_index(:packages_cleanup_policies, [:next_run_at, :project_id], {:where=>"keep_n_duplicated_package_files <> 'all'", :name=>"idx_enabled_pkgs_cleanup_policies_on_next_run_at_project_id", :algorithm=>:concurrently})
-> 0.0101s
-- execute("RESET statement_timeout")
-> 0.0005s
-- transaction_open?(nil)
-> 0.0000s
-- view_exists?(:postgres_partitions)
-> 0.0007s
-- index_name_exists?(:packages_cleanup_policies, "idx_active_pkgs_cleanup_policies_on_next_run_at_project_id")
-> 0.0012s
-- remove_index(:packages_cleanup_policies, {:algorithm=>:concurrently, :name=>"idx_active_pkgs_cleanup_policies_on_next_run_at_project_id"})
-> 0.0062s
== 20260924180100 ReplacePackagesCleanupPoliciesNextRunAtIndex: reverted (0.1570s)
== 20260924180000 AddDeleteGenericPackagesNotDownloadedInDaysToPackagesCleanupPolicies: reverting
-- transaction_open?(nil)
-> 0.0000s
-- remove_column(:packages_cleanup_policies, :delete_generic_packages_not_downloaded_in_days, {:if_exists=>true})
-> 0.0399s
== 20260924180000 AddDeleteGenericPackagesNotDownloadedInDaysToPackagesCleanupPolicies: reverted (0.0692s)
# up (both)
== 20260924180000 AddDeleteGenericPackagesNotDownloadedInDaysToPackagesCleanupPolicies: migrating
-- transaction_open?(nil)
-> 0.0000s
-- add_column(:packages_cleanup_policies, :delete_generic_packages_not_downloaded_in_days, :integer, {:if_not_exists=>true})
-> 0.0346s
-- transaction_open?(nil)
-> 0.0000s
-- transaction_open?(nil)
-> 0.0000s
-- execute("ALTER TABLE packages_cleanup_policies\nADD CONSTRAINT check_packages_cleanup_policies_generic_not_downloaded_days\nCHECK ( delete_generic_packages_not_downloaded_in_days > 0 AND delete_generic_packages_not_downloaded_in_days <= 3650 )\nNOT VALID;\n")
-> 0.0010s
-- execute("SET statement_timeout TO 0")
-> 0.0004s
-- execute("ALTER TABLE packages_cleanup_policies VALIDATE CONSTRAINT check_packages_cleanup_policies_generic_not_downloaded_days;")
-> 0.0008s
-- execute("RESET statement_timeout")
-> 0.0004s
== 20260924180000 AddDeleteGenericPackagesNotDownloadedInDaysToPackagesCleanupPolicies: migrated (0.0624s)
== 20260924180100 ReplacePackagesCleanupPoliciesNextRunAtIndex: migrating =====
-- transaction_open?(nil)
-> 0.0000s
-- view_exists?(:postgres_partitions)
-> 0.0454s
-- index_exists?(:packages_cleanup_policies, [:next_run_at, :project_id], {:where=>"keep_n_duplicated_package_files <> 'all' OR delete_generic_packages_not_downloaded_in_days IS NOT NULL", :name=>"idx_active_pkgs_cleanup_policies_on_next_run_at_project_id", :algorithm=>:concurrently})
-> 0.0029s
-- execute("SET statement_timeout TO 0")
-> 0.0004s
-- add_index(:packages_cleanup_policies, [:next_run_at, :project_id], {:where=>"keep_n_duplicated_package_files <> 'all' OR delete_generic_packages_not_downloaded_in_days IS NOT NULL", :name=>"idx_active_pkgs_cleanup_policies_on_next_run_at_project_id", :algorithm=>:concurrently})
-> 0.0066s
-- execute("RESET statement_timeout")
-> 0.0003s
-- transaction_open?(nil)
-> 0.0000s
-- view_exists?(:postgres_partitions)
-> 0.0004s
-- index_name_exists?(:packages_cleanup_policies, "idx_enabled_pkgs_cleanup_policies_on_next_run_at_project_id")
-> 0.0017s
-- remove_index(:packages_cleanup_policies, {:algorithm=>:concurrently, :name=>"idx_enabled_pkgs_cleanup_policies_on_next_run_at_project_id"})
-> 0.0052s
== 20260924180100 ReplacePackagesCleanupPoliciesNextRunAtIndex: migrated (0.1440s)Query plans
Plans come from postgres.ai's gitlab-production-main clone. The clone has this MR's column and index applied, the rule turned on for 5,000 policies, and 20,000 seeded generic packages (16,000 older than 30 days) plus one file each and two delete protection rules, all under project 278964.
| Query | Where | Plan | Time (warm) |
|---|---|---|---|
1a Scheduler: next policy (Policy.runnable ... FOR UPDATE SKIP LOCKED) |
ExecutePolicyWorker#next_policy |
163725 | 154 ms |
| 1a baseline (master, without the new OR) | — | 163726 | 62 ms |
| 1b Scheduler: remaining work count | ExecutePolicyWorker#remaining_work_count |
163718 | 1.1 s cold |
| 2a Batch start | ExecutePolicyService#delete_generic_packages_not_downloaded_with_status |
163719 | 1.8 ms |
| 2b Batch stop | same | 163720 | 0.5 ms |
| 3 Batch filter (generic, not downloaded since, no recent upload, no delete protection rule) | same | 166285 | about 4 ms |
| 3 before the recent-upload check | — | 163721 | 2.4 ms |
4 Mark packages pending_destruction |
mark_packages_for_destruction |
163722 | 40 ms |
| 5a Mark files: batch start | MarkPackageFilesForDestructionService (existing) |
163723 | 4 ms |
| 5b Mark files: batch update | same | 163727 | 18 ms |
Notes
- Scheduler cost: 1a and its baseline have the same plan shape: an index scan on
packages_cleanup_policies_pkeywith a filter, then the existingEXISTSsemi-join onindex_packages_packages_on_project_id_and_package_type. Buffers go from 92,015 to 95,510 (+4%), in line with the extra policies the new OR matches (17,310 vs 16,480 rows). The first cold run took 13.9 s, almost all I/O (163717). - Partial index not chosen: on production the planner doesn't use the partial
next_run_atindex for 1a, before or after this MR. The index swap keeps its predicate in sync with theactivescope. It doesn't change this plan, but the old index could never serve the updated scope, because its predicate doesn't cover the newOR. - Batch filter: the earlier plan (163721) returns 389 of 500 rows. The 111 excluded rows match the
seed-build-cache-1*delete protection rule, so thatNOT EXISTSworks. - Recent uploads: plan 166285 adds the
NOT EXISTSonpackages_package_files. It uses an Index Only Scan onindex_packages_package_files_on_package_id_and_created_at_desc, 500 loops at 0.002 ms, about 1 ms per batch. It returns 450 of 500 seeded packages. The 50 kept packages each had a file uploaded 1 day ago. The plan's 2.4 s total is a cold read in Joe'sInitPlanthat finds the seeded id range. The production query passes literal ids, and its own nodes take about 4 ms. db:check-migrationsskipped: the job failed with "rollback of added migrations does not revert db/structure.sql". Its diff contains none of this MR's tables or indexes, only master's:ai_events_countsmoves position andorbit_namespace_enrollments(added to master after this branch was rebased) disappears. This MR's two migrations roll back cleanly, so the job is skipped with pipeline:skip-check-migrations.- Update batch sizes: query 4 now runs 100 package ids at a time, and 5b marks 200 files at a time, matching the duplicate files rule. The plans above were measured at 500 rows, so these batches touch fewer rows than shown.
- 5b: the app sends a literal id list. The plan uses
packages_package_files_pkeywith the batch id range. An earlier run with a subquery instead of literal ids took 545 ms (163724) and isn't representative.
SQL
-- 1a Scheduler: next policy
SELECT "packages_cleanup_policies".* FROM "packages_cleanup_policies" WHERE ("packages_cleanup_policies"."keep_n_duplicated_package_files" != 'all' OR "packages_cleanup_policies"."delete_generic_packages_not_downloaded_in_days" IS NOT NULL) AND (next_run_at < NOW()) AND (EXISTS (SELECT 1 FROM "packages_packages" WHERE "packages_packages"."status" IN (0, 1, 5) AND (packages_packages.project_id = packages_cleanup_policies.project_id))) ORDER BY "packages_cleanup_policies"."next_run_at" ASC LIMIT 1 FOR UPDATE SKIP LOCKED;
-- 1b Scheduler: remaining work count
SELECT COUNT(*) FROM (SELECT 1 FROM "packages_cleanup_policies" WHERE ("packages_cleanup_policies"."keep_n_duplicated_package_files" != 'all' OR "packages_cleanup_policies"."delete_generic_packages_not_downloaded_in_days" IS NOT NULL) AND (next_run_at < NOW()) AND (EXISTS (SELECT 1 FROM "packages_packages" WHERE "packages_packages"."status" IN (0, 1, 5) AND (packages_packages.project_id = packages_cleanup_policies.project_id))) ORDER BY "packages_cleanup_policies"."next_run_at" ASC LIMIT 21) subquery_for_count;
-- 2a Batch start
SELECT "packages_packages"."id" FROM "packages_packages" WHERE "packages_packages"."project_id" = 278964 AND "packages_packages"."status" = 0 ORDER BY "packages_packages"."id" ASC LIMIT 1;
-- 2b Batch stop
SELECT "packages_packages"."id" FROM "packages_packages" WHERE "packages_packages"."project_id" = 278964 AND "packages_packages"."status" = 0 AND (packages_packages.id >= 1) ORDER BY "packages_packages"."id" ASC LIMIT 1 OFFSET 500;
-- 3 Batch filter
SELECT "packages_packages"."id" FROM "packages_packages" WHERE "packages_packages"."project_id" = 278964 AND "packages_packages"."status" = 0 AND (packages_packages.id >= 1 AND packages_packages.id < 1000000) AND "packages_packages"."package_type" = 7 AND COALESCE("packages_packages"."last_downloaded_at", "packages_packages"."created_at") < NOW() - INTERVAL '30 days' AND (NOT EXISTS (SELECT 1 FROM "packages_package_files" WHERE "packages_package_files"."package_id" = "packages_packages"."id" AND "packages_package_files"."created_at" >= NOW() - INTERVAL '30 days')) AND (NOT EXISTS (SELECT 1 FROM "packages_protection_rules" WHERE "packages_protection_rules"."project_id" = "packages_packages"."project_id" AND "packages_protection_rules"."package_type" = "packages_packages"."package_type" AND "packages_protection_rules"."minimum_access_level_for_delete" IS NOT NULL AND ("packages_packages".name ILIKE REPLACE(REPLACE(REPLACE(package_name_pattern, '%', '\%'), '_', '\_'), '*', '%'))));
-- 4 Mark packages pending_destruction
UPDATE "packages_packages" SET "status" = 4 WHERE "packages_packages"."id" IN (<up to 100 ids>);
-- 5a Mark files: batch start
SELECT "packages_package_files"."id" FROM "packages_package_files" WHERE "packages_package_files"."package_id" IN (<up to 500 ids>) ORDER BY "packages_package_files"."id" ASC LIMIT 1;
-- 5b Mark files: batch update
UPDATE "packages_package_files" SET "status" = 1 WHERE "packages_package_files"."package_id" IN (<up to 500 ids>) AND "packages_package_files"."id" BETWEEN 1 AND 200;MR acceptance checklist
Evaluate this MR against the MR acceptance checklist. It helps you analyze changes to reduce risks in quality, performance, reliability, security, and maintainability.
Edited by Tim Rizzi