5.1 KiB · text History b868907
defmodule GitGud.Packages.DiskUsage do
  @moduledoc """
  Where the registry's disk went.

  Every number here is one aggregate query rather than a walk: an
  instance with a few hundred thousand files should be able to render
  the admin page without thinking about it.

  `SUM` over a bigint column is `numeric` in Postgres, which Ecto loads
  as a `Decimal` — so every sum here is cast back to an integer in the
  query rather than leaving each caller to discover that the hard way.

  Sizes come from `package_files.size` where a format has files, and
  from `package_versions.size` where it doesn't — OCI's bytes live in
  zot, but they are still bytes this instance is answerable for, so
  they are counted and labelled rather than left out of the total.
  """

  import Ecto.Query, warn: false

  alias GitGud.Organizations.Organization
  alias GitGud.Packages.Package
  alias GitGud.Packages.PackageFile
  alias GitGud.Packages.PackageVersion
  alias GitGud.Repo
  alias GitGud.Repositories.Repository

  @typedoc "Bytes and counts for one grouping."
  @type row :: %{
          key: String.t() | integer(),
          label: String.t(),
          bytes: non_neg_integer(),
          versions: non_neg_integer(),
          packages: non_neg_integer()
        }

  @doc """
  Instance-wide totals, one row per format, largest first.

  The shape every view starts from: what is on this disk, and which
  ecosystem put it there.
  """
  @spec by_format(keyword()) :: [row()]
  def by_format(opts \\ []) do
    base()
    |> scope(opts)
    |> group_by([p], p.format)
    |> select([p, v, f], %{
      key: p.format,
      bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer),
      versions: count(v.id, :distinct),
      packages: count(p.id, :distinct)
    })
    |> Repo.all()
    |> Enum.map(&Map.put(&1, :label, &1.key))
    |> Enum.sort_by(& &1.bytes, :desc)
  end

  @doc "One row per repository, largest first. Scoped like `by_format/1`."
  @spec by_repository(keyword()) :: [row()]
  def by_repository(opts \\ []) do
    base()
    |> scope(opts)
    |> join(:inner, [p], r in Repository, on: r.id == p.repository_id)
    |> group_by([p, v, f, r], [r.id, r.name])
    |> select([p, v, f, r], %{
      key: r.id,
      label: r.name,
      bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer),
      versions: count(v.id, :distinct),
      packages: count(p.id, :distinct)
    })
    |> Repo.all()
    |> Enum.sort_by(& &1.bytes, :desc)
  end

  @doc """
  The biggest packages, largest first — what to look at when the
  number at the top of the page is a surprise.
  """
  @spec top_packages(keyword()) :: [map()]
  def top_packages(opts \\ []) do
    limit = Keyword.get(opts, :limit, 20)

    base()
    |> scope(opts)
    |> join(:inner, [p], r in Repository, on: r.id == p.repository_id)
    |> group_by([p, v, f, r], [p.id, p.name, p.format, r.id, r.name])
    |> select([p, v, f, r], %{
      package_id: p.id,
      name: p.name,
      format: p.format,
      repository_id: r.id,
      repository: r.name,
      bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer),
      versions: count(v.id, :distinct)
    })
    |> order_by([p, v, f], desc: coalesce(sum(coalesce(f.size, v.size)), 0))
    |> limit(^limit)
    |> Repo.all()
  end

  @doc "One number: everything in scope."
  @spec total(keyword()) :: %{
          bytes: non_neg_integer(),
          packages: non_neg_integer(),
          versions: non_neg_integer()
        }
  def total(opts \\ []) do
    base()
    |> scope(opts)
    |> select([p, v, f], %{
      bytes: type(coalesce(sum(coalesce(f.size, v.size)), 0), :integer),
      versions: count(v.id, :distinct),
      packages: count(p.id, :distinct)
    })
    |> Repo.one()
  end

  # Files left-joined so a version with no file rows (OCI) still counts
  # once, at the version's own size.
  defp base do
    from(p in Package,
      left_join: v in PackageVersion,
      on: v.package_id == p.id,
      left_join: f in PackageFile,
      on: f.package_version_id == v.id
    )
  end

  defp scope(query, opts) do
    Enum.reduce(opts, query, fn
      {:repository, %Repository{id: id}}, q -> where(q, [p], p.repository_id == ^id)
      {:repository_id, id}, q -> where(q, [p], p.repository_id == ^id)
      {:organization, %Organization{id: id}}, q -> scope_org(q, id)
      {:organization_id, id}, q -> scope_org(q, id)
      {:format, format}, q -> where(q, [p], p.format == ^format)
      _, q -> q
    end)
  end

  defp scope_org(query, org_id) do
    repos = from(r in Repository, where: r.organization_id == ^org_id, select: r.id)
    where(query, [p], p.repository_id in subquery(repos))
  end

  @doc """
  Human bytes. Binary units, because that is what a disk is measured
  in and what every other size in this app uses.
  """
  @spec humanize(integer() | nil) :: String.t()
  def humanize(nil), do: "0 B"
  def humanize(bytes) when bytes < 1024, do: "#{bytes} B"
  def humanize(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1024, 1)} KiB"
  def humanize(bytes) when bytes < 1_073_741_824, do: "#{Float.round(bytes / 1_048_576, 1)} MiB"
  def humanize(bytes), do: "#{Float.round(bytes / 1_073_741_824, 2)} GiB"
end