neiam
/gitgud
Git Gud
public · Issues · Pulls · Labels · Forks · Compare · Actions success · Badges · Packages
Log in to mark this repository.
Package retention, and where the registry disk went
aa9e861 · Gabriel Morell · 2026-09-12 14:22
Message
{commit_body(@commit)}
Files changed
modified
config/config.exs
+3
−0
| 60 | 60 | # Re-sync repositories that opted into a scheduled upstream |
| 61 | 61 | # fetch. Anonymous — a private upstream is synced by hand. |
| 62 | 62 | {"@hourly", GitGud.Repositories.Workers.UpstreamSync}, |
| 63 | + # Retention pass over the package registries. Reports rather | |
| 64 | + # than deletes until `packages.retention_enforced` is on. | |
| 65 | + {"@daily", GitGud.Packages.Workers.Prune}, | |
| 63 | 66 | # Prune `actions/cache` entries by age + per-repo size cap. |
| 64 | 67 | {"@hourly", GitGud.Workflows.Workers.CacheEviction}, |
| 65 | 68 | # Requeue workflow_jobs stuck in `running` after a claim-then- |
modified
lib/git_gud/packages.ex
+6
−1
| 102 | 102 | def list_versions(%Package{id: pid}) do |
| 103 | 103 | PackageVersion |
| 104 | 104 | |> where([v], v.package_id == ^pid) |
| 105 | − |> order_by([v], desc: v.pushed_at) | |
| 105 | + # `id` breaks the tie: `pushed_at` has second granularity, so two | |
| 106 | + # versions published in the same second otherwise come back in | |
| 107 | + # whatever order the plan happened to produce — which changes when | |
| 108 | + # an index is added, and leaves order-sensitive formats (cargo's | |
| 109 | + # index is one) flapping between requests. | |
| 110 | + |> order_by([v], desc: v.pushed_at, desc: v.id) | |
| 106 | 111 | |> Repo.all() |
| 107 | 112 | end |
| 108 | 113 |
added
lib/git_gud/packages/disk_usage.ex
+157
−0
| 1 | +defmodule GitGud.Packages.DiskUsage do | |
| 2 | + @moduledoc """ | |
| 3 | + Where the registry's disk went. | |
| 4 | + | |
| 5 | + Every number here is one aggregate query rather than a walk: an | |
| 6 | + instance with a few hundred thousand files should be able to render | |
| 7 | + the admin page without thinking about it. | |
| 8 | + | |
| 9 | + `SUM` over a bigint column is `numeric` in Postgres, which Ecto loads | |
| 10 | + as a `Decimal` — so every sum here is cast back to an integer in the | |
| 11 | + query rather than leaving each caller to discover that the hard way. | |
| 12 | + | |
| 13 | + Sizes come from `package_files.size` where a format has files, and | |
| 14 | + from `package_versions.size` where it doesn't — OCI's bytes live in | |
| 15 | + zot, but they are still bytes this instance is answerable for, so | |
| 16 | + they are counted and labelled rather than left out of the total. | |
| 17 | + """ | |
| 18 | + | |
| 19 | + import Ecto.Query, warn: false | |
| 20 | + | |
| 21 | + alias GitGud.Organizations.Organization | |
| 22 | + alias GitGud.Packages.Package | |
| 23 | + alias GitGud.Packages.PackageFile | |
| 24 | + alias GitGud.Packages.PackageVersion | |
| 25 | + alias GitGud.Repo | |
| 26 | + alias GitGud.Repositories.Repository | |
| 27 | + | |
| 28 | + @typedoc "Bytes and counts for one grouping." | |
| 29 | + @type row :: %{ | |
| 30 | + key: String.t() | integer(), | |
| 31 | + label: String.t(), | |
| 32 | + bytes: non_neg_integer(), | |
| 33 | + versions: non_neg_integer(), | |
| 34 | + packages: non_neg_integer() | |
| 35 | + } | |
| 36 | + | |
| 37 | + @doc """ | |
| 38 | + Instance-wide totals, one row per format, largest first. | |
| 39 | + | |
| 40 | + The shape every view starts from: what is on this disk, and which | |
| 41 | + ecosystem put it there. | |
| 42 | + """ | |
| 43 | + @spec by_format(keyword()) :: [row()] | |
| 44 | + def by_format(opts \\ []) do | |
| 45 | + base() | |
| 46 | + |> scope(opts) | |
| 47 | + |> group_by([p], p.format) | |
| 48 | + |> select([p, v, f], %{ | |
| 49 | + key: p.format, | |
| 50 | + bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer), | |
| 51 | + versions: count(v.id, :distinct), | |
| 52 | + packages: count(p.id, :distinct) | |
| 53 | + }) | |
| 54 | + |> Repo.all() | |
| 55 | + |> Enum.map(&Map.put(&1, :label, &1.key)) | |
| 56 | + |> Enum.sort_by(& &1.bytes, :desc) | |
| 57 | + end | |
| 58 | + | |
| 59 | + @doc "One row per repository, largest first. Scoped like `by_format/1`." | |
| 60 | + @spec by_repository(keyword()) :: [row()] | |
| 61 | + def by_repository(opts \\ []) do | |
| 62 | + base() | |
| 63 | + |> scope(opts) | |
| 64 | + |> join(:inner, [p], r in Repository, on: r.id == p.repository_id) | |
| 65 | + |> group_by([p, v, f, r], [r.id, r.name]) | |
| 66 | + |> select([p, v, f, r], %{ | |
| 67 | + key: r.id, | |
| 68 | + label: r.name, | |
| 69 | + bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer), | |
| 70 | + versions: count(v.id, :distinct), | |
| 71 | + packages: count(p.id, :distinct) | |
| 72 | + }) | |
| 73 | + |> Repo.all() | |
| 74 | + |> Enum.sort_by(& &1.bytes, :desc) | |
| 75 | + end | |
| 76 | + | |
| 77 | + @doc """ | |
| 78 | + The biggest packages, largest first — what to look at when the | |
| 79 | + number at the top of the page is a surprise. | |
| 80 | + """ | |
| 81 | + @spec top_packages(keyword()) :: [map()] | |
| 82 | + def top_packages(opts \\ []) do | |
| 83 | + limit = Keyword.get(opts, :limit, 20) | |
| 84 | + | |
| 85 | + base() | |
| 86 | + |> scope(opts) | |
| 87 | + |> join(:inner, [p], r in Repository, on: r.id == p.repository_id) | |
| 88 | + |> group_by([p, v, f, r], [p.id, p.name, p.format, r.id, r.name]) | |
| 89 | + |> select([p, v, f, r], %{ | |
| 90 | + package_id: p.id, | |
| 91 | + name: p.name, | |
| 92 | + format: p.format, | |
| 93 | + repository_id: r.id, | |
| 94 | + repository: r.name, | |
| 95 | + bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer), | |
| 96 | + versions: count(v.id, :distinct) | |
| 97 | + }) | |
| 98 | + |> order_by([p, v, f], desc: coalesce(sum(coalesce(f.size, v.size)), 0)) | |
| 99 | + |> limit(^limit) | |
| 100 | + |> Repo.all() | |
| 101 | + end | |
| 102 | + | |
| 103 | + @doc "One number: everything in scope." | |
| 104 | + @spec total(keyword()) :: %{ | |
| 105 | + bytes: non_neg_integer(), | |
| 106 | + packages: non_neg_integer(), | |
| 107 | + versions: non_neg_integer() | |
| 108 | + } | |
| 109 | + def total(opts \\ []) do | |
| 110 | + base() | |
| 111 | + |> scope(opts) | |
| 112 | + |> select([p, v, f], %{ | |
| 113 | + bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer), | |
| 114 | + versions: count(v.id, :distinct), | |
| 115 | + packages: count(p.id, :distinct) | |
| 116 | + }) | |
| 117 | + |> Repo.one() | |
| 118 | + end | |
| 119 | + | |
| 120 | + # Files left-joined so a version with no file rows (OCI) still counts | |
| 121 | + # once, at the version's own size. | |
| 122 | + defp base do | |
| 123 | + from(p in Package, | |
| 124 | + left_join: v in PackageVersion, | |
| 125 | + on: v.package_id == p.id, | |
| 126 | + left_join: f in PackageFile, | |
| 127 | + on: f.package_version_id == v.id | |
| 128 | + ) | |
| 129 | + end | |
| 130 | + | |
| 131 | + defp scope(query, opts) do | |
| 132 | + Enum.reduce(opts, query, fn | |
| 133 | + {:repository, %Repository{id: id}}, q -> where(q, [p], p.repository_id == ^id) | |
| 134 | + {:repository_id, id}, q -> where(q, [p], p.repository_id == ^id) | |
| 135 | + {:organization, %Organization{id: id}}, q -> scope_org(q, id) | |
| 136 | + {:organization_id, id}, q -> scope_org(q, id) | |
| 137 | + {:format, format}, q -> where(q, [p], p.format == ^format) | |
| 138 | + _, q -> q | |
| 139 | + end) | |
| 140 | + end | |
| 141 | + | |
| 142 | + defp scope_org(query, org_id) do | |
| 143 | + repos = from(r in Repository, where: r.organization_id == ^org_id, select: r.id) | |
| 144 | + where(query, [p], p.repository_id in subquery(repos)) | |
| 145 | + end | |
| 146 | + | |
| 147 | + @doc """ | |
| 148 | + Human bytes. Binary units, because that is what a disk is measured | |
| 149 | + in and what every other size in this app uses. | |
| 150 | + """ | |
| 151 | + @spec humanize(integer() | nil) :: String.t() | |
| 152 | + def humanize(nil), do: "0 B" | |
| 153 | + def humanize(bytes) when bytes < 1024, do: "#{bytes} B" | |
| 154 | + def humanize(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KiB" | |
| 155 | + def humanize(bytes) when bytes < 1_073_741_824, do: "#{Float.round(bytes / 1_048_576, 1)} MiB" | |
| 156 | + def humanize(bytes), do: "#{Float.round(bytes / 1_073_741_824, 2)} GiB" | |
| 157 | +end |
modified
lib/git_gud/packages/formats/cargo.ex
+4
−1
| 178 | 178 | defp index_body(%Package{} = package) do |
| 179 | 179 | package |
| 180 | 180 | |> Packages.list_versions() |
| 181 | − |> Enum.sort_by(& &1.pushed_at, DateTime) | |
| 181 | + # Oldest first, and stable within a second — cargo caches these | |
| 182 | + # lines, so the same set in a different order is a cache that | |
| 183 | + # thrashes for no reason. | |
| 184 | + |> Enum.sort_by(&{DateTime.to_unix(&1.pushed_at), &1.id}) | |
| 182 | 185 | |> Enum.map(&[Jason.encode_to_iodata!(index_entry(package, &1)), "\n"]) |
| 183 | 186 | end |
| 184 | 187 |
modified
lib/git_gud/packages/package.ex
+21
−0
| 20 | 20 | field :name, :string |
| 21 | 21 | field :normalized_name, :string |
| 22 | 22 | field :latest_version, :string |
| 23 | + # nil on any of these means "inherit the instance policy" — which | |
| 24 | + # is what lets an admin move the ceiling without rewriting rows. | |
| 25 | + # See `GitGud.Packages.Retention`. | |
| 26 | + field :retention_mode, :string | |
| 27 | + field :retention_keep, :integer | |
| 28 | + field :retention_bytes, :integer | |
| 23 | 29 | |
| 24 | 30 | belongs_to :repository, Repository |
| 25 | 31 | has_many :versions, PackageVersion |
| 37 | 43 | |> unique_constraint([:repository_id, :format, :normalized_name]) |
| 38 | 44 | end |
| 39 | 45 | |
| 46 | + @doc """ | |
| 47 | + Changeset for a package's own retention policy. | |
| 48 | + | |
| 49 | + Bounds aren't checked here — "stricter than the instance" is a | |
| 50 | + comparison against a value this module can't see. `Retention.set_policy/2` | |
| 51 | + owns that, and this only casts. | |
| 52 | + """ | |
| 53 | + def retention_changeset(package, attrs) do | |
| 54 | + package | |
| 55 | + |> cast(attrs, [:retention_mode, :retention_keep, :retention_bytes]) | |
| 56 | + |> validate_inclusion(:retention_mode, ~w(keep_last quota)) | |
| 57 | + |> validate_number(:retention_keep, greater_than: 0) | |
| 58 | + |> validate_number(:retention_bytes, greater_than_or_equal_to: 0) | |
| 59 | + end | |
| 60 | + | |
| 40 | 61 | # The normalized name is derived, not supplied: letting a caller set |
| 41 | 62 | # it independently of `name` is how you get two rows that a client |
| 42 | 63 | # sees as one package. |
modified
lib/git_gud/packages/package_version.ex
+5
−0
Click to load diff…
added
lib/git_gud/packages/pruner.ex
+221
−0
Click to load diff…
added
lib/git_gud/packages/retention.ex
+350
−0
Click to load diff…
added
lib/git_gud/packages/workers/prune.ex
+51
−0
Click to load diff…
added
lib/git_gud/packages/zot.ex
+72
−0
Click to load diff…
added
lib/git_gud/settings.ex
+167
−0
Click to load diff…
added
lib/git_gud/settings/instance_setting.ex
+27
−0
Click to load diff…
added
lib/git_gud_web/live/admin_packages_live/index.ex
+321
−0
Click to load diff…
added
lib/git_gud_web/live/org_live/packages.ex
+186
−0
Click to load diff…
modified
lib/git_gud_web/live/repo_live/packages.ex
+199
−2
Click to load diff…
modified
lib/git_gud_web/router.ex
+2
−0
Click to load diff…
added
priv/repo/migrations/20260912100000_create_instance_settings.exs
+26
−0
Click to load diff…
added
priv/repo/migrations/20260912100100_add_package_retention.exs
+35
−0
Click to load diff…
added
test/git_gud/packages/pruner_test.exs
+163
−0
Click to load diff…
added
test/git_gud/packages/retention_test.exs
+239
−0
Click to load diff…
added
test/git_gud_web/live/admin_packages_test.exs
+116
−0
Click to load diff…
added
test/git_gud_web/live/package_retention_ui_test.exs
+172
−0
Click to load diff…
Parents: 1ecec4a