Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions lib/hex/cooldown.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
defmodule Hex.Cooldown do
@moduledoc false

@type duration :: String.t()
@type cutoff :: {:cutoff, integer(), non_neg_integer()} | :disabled

@doc """
Parses a duration string into a normalized form for `Hex.State`.

Returns `{:ok, duration}` for valid input or `:error` for invalid input.

Accepted forms: `"0"`, `"<N>d"`, `"<N>w"`, `"<N>mo"`.
"""
@spec parse_config(String.t() | nil) :: {:ok, duration()} | :error
def parse_config(nil), do: {:ok, "0d"}
def parse_config(""), do: {:ok, "0d"}

def parse_config(string) when is_binary(string) do
case duration_to_seconds(string) do
{:ok, _} -> {:ok, string}
:error -> :error
end
end

def parse_config(_), do: :error

@doc """
Parses the `cooldown_exclude_repos` config value into a list of repo
name strings.

Accepts either a list of strings (project / global config) or a
comma-separated string (env var). Empty entries are dropped, whitespace
is trimmed. Repo names should match `Hex.Repo` keys, e.g. `"hexpm"`,
`"hexpm:myorg"`, or a custom repo name.
"""
@spec parse_exclude_repos([String.t()] | String.t() | nil) :: {:ok, [String.t()]} | :error
def parse_exclude_repos(nil), do: {:ok, []}
def parse_exclude_repos([]), do: {:ok, []}

def parse_exclude_repos(list) when is_list(list) do
if Enum.all?(list, &is_binary/1) do
{:ok, list |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == ""))}
else
:error
end
end

def parse_exclude_repos(string) when is_binary(string) do
{:ok,
string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))}
end

def parse_exclude_repos(_), do: :error

@doc """
Returns true if cooldown should be skipped for the given repo.
"""
@spec repo_excluded?(String.t() | nil) :: boolean()
def repo_excluded?(repo) do
name = repo || "hexpm"
name in Hex.State.fetch!(:cooldown_exclude_repos)
end

@doc """
Converts a duration string into a number of seconds.
"""
@spec duration_to_seconds(String.t()) :: {:ok, non_neg_integer()} | :error
def duration_to_seconds("0"), do: {:ok, 0}

def duration_to_seconds(string) when is_binary(string) do
with [_, digits, unit] <- Regex.run(~r/\A(\d+)(d|w|mo)\z/, string),
{n, ""} <- Integer.parse(digits) do
{:ok, n * unit_seconds(unit)}
else
_ -> :error
end
end

defp unit_seconds("d"), do: 86_400
defp unit_seconds("w"), do: 86_400 * 7
defp unit_seconds("mo"), do: 86_400 * 30

@doc """
Builds a resolution cutoff from the local cooldown configuration.

Returns `:disabled` when the effective duration is zero.
"""
@spec build_cutoff() :: cutoff()
def build_cutoff() do
case duration_to_seconds(Hex.State.fetch!(:cooldown)) do
{:ok, 0} ->
:disabled

{:ok, seconds} ->
now = System.system_time(:second)
{:cutoff, now - seconds, seconds}

:error ->
:disabled
end
end

@doc """
Returns true if the release is eligible under the cutoff.

A release with no `published_at` (legacy registry data) is treated as
eligible; cooldown only applies to releases whose publish time is known.
"""
@spec eligible?(integer() | nil, cutoff()) :: boolean()
def eligible?(_published_at, :disabled), do: true
def eligible?(nil, _cutoff), do: true

def eligible?(published_at, {:cutoff, cutoff_seconds, _}) when is_integer(published_at) do
published_at <= cutoff_seconds
end

@doc """
Returns the date a release would become eligible under the cutoff.
"""
@spec eligible_on(integer(), cutoff()) :: Date.t()
def eligible_on(published_at, {:cutoff, _cutoff_seconds, window_seconds})
when is_integer(published_at) do
published_at
|> Kernel.+(window_seconds)
|> DateTime.from_unix!()
|> DateTime.to_date()
end

@doc """
Formats the post-solver summary of versions skipped by cooldown.

Takes a list of `{repo, package, version, published_at}` tuples accumulated
during the solve. Returns `nil` when nothing eligible to report — empty
input, disabled cutoff, or every entry missing a `published_at`.

Entries are deduplicated and sorted by package then version. Repo is used
for keying only; it is not rendered.
"""
@spec format_summary([{String.t(), String.t(), String.t(), integer() | nil}], cutoff()) ::
String.t() | nil
def format_summary(_entries, :disabled), do: nil
def format_summary([], _cutoff), do: nil

def format_summary(entries, cutoff) do
entries =
entries
|> Enum.reject(fn {_repo, _pkg, _vsn, published_at} -> is_nil(published_at) end)
|> Enum.uniq()
|> Enum.sort_by(fn {repo, pkg, vsn, _} -> {repo, pkg, vsn} end)

case entries do
[] ->
nil

entries ->
today = Date.utc_today()

lines =
Enum.map(entries, fn {_repo, pkg, vsn, published_at} ->
published_date = published_at |> DateTime.from_unix!() |> DateTime.to_date()
days_ago = Date.diff(today, published_date)
eligible_date = eligible_on(published_at, cutoff)
" #{pkg} #{vsn} — published #{days_ago} days ago, eligible #{eligible_date}"
end)

"\nVersions filtered by cooldown:\n" <> Enum.join(lines, "\n") <> "\n"
end
end
end
3 changes: 2 additions & 1 deletion lib/hex/dev.ex
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ if Mix.env() == :dev do
@ets_name __MODULE__
@registry_filename "cache.ets"
@repo "hexpm"
@ets_version 4
@ets_version 5

def extract_registry(packages, new_path) do
{:ok, original_ets} = :ets.file2tab(String.to_charlist(ets_path()))
Expand Down Expand Up @@ -42,6 +42,7 @@ if Mix.env() == :dev do
copy(original_ets, new_ets, {:outer_checksum, @repo, package, version})
copy(original_ets, new_ets, {:retired, @repo, package, version})
copy(original_ets, new_ets, {:advisories, @repo, package, version})
copy(original_ets, new_ets, {:published_at, @repo, package, version})
copy(original_ets, new_ets, {:timestamp, @repo, package, version})

[{_, dep_tuples}] = :ets.lookup(original_ets, {:deps, @repo, package, version})
Expand Down
73 changes: 73 additions & 0 deletions lib/hex/registry/cooldown.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
defmodule Hex.Registry.Cooldown do
@moduledoc false

@behaviour Hex.Solver.Registry

alias Hex.Registry.Server

@impl true
defdelegate prefetch(packages), to: Server

@impl true
defdelegate dependencies(repo, package, version), to: Server

@impl true
def versions(repo, package) do
case Server.versions(repo, package) do
{:ok, versions} ->
cutoff = Hex.State.fetch!(:cooldown_cutoff)
bypass = Hex.State.fetch!(:cooldown_bypass_packages)

cond do
cutoff == :disabled ->
{:ok, versions}

Hex.Cooldown.repo_excluded?(repo) ->
{:ok, versions}

MapSet.member?(bypass, package) ->
{:ok, versions}

true ->
locked =
Map.get(Hex.State.fetch!(:cooldown_locked_versions), {repo || "hexpm", package}, [])

{:ok, filter(versions, repo, package, cutoff, locked)}
end

:error ->
:error
end
end

defp filter(versions, repo, package, cutoff, locked) do
{eligible, filtered_out} =
Enum.split_with(versions, fn version ->
version_str = to_string(version)

if version_str in locked do
true
else
published_at = Server.published_at(repo, package, version_str)
Hex.Cooldown.eligible?(published_at, cutoff)
end
end)

record_filtered(repo, package, filtered_out)
eligible
end

defp record_filtered(_repo, _package, []), do: :ok

defp record_filtered(repo, package, versions) do
repo_key = repo || "hexpm"

entries =
Enum.map(versions, fn version ->
version_str = to_string(version)
{repo_key, package, version_str, Server.published_at(repo, package, version_str)}
end)

Hex.State.update!(:cooldown_filtered_versions, &(entries ++ &1))
end
end
26 changes: 25 additions & 1 deletion lib/hex/registry/server.ex
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ defmodule Hex.Registry.Server do
@name __MODULE__
@filename "cache.ets"
@timeout 60_000
@ets_version 4
@ets_version 5
@public_keys_html "https://hex.pm/docs/public_keys"

def start_link(opts \\ []) do
Expand Down Expand Up @@ -58,6 +58,10 @@ defmodule Hex.Registry.Server do
GenServer.call(@name, {:advisories, repo, package, version}, @timeout)
end

def published_at(repo, package, version) do
GenServer.call(@name, {:published_at, repo, package, version}, @timeout)
end

def last_update() do
GenServer.call(@name, :last_update, @timeout)
end
Expand Down Expand Up @@ -216,6 +220,12 @@ defmodule Hex.Registry.Server do
end)
end

def handle_call({:published_at, repo, package, version}, from, state) do
maybe_wait({repo, package}, from, state, fn ->
lookup(state.ets, {:published_at, repo || "hexpm", package, version})
end)
end

def handle_call(:last_update, _from, state) do
time = lookup(state.ets, :last_update)
{:reply, time, state}
Expand Down Expand Up @@ -313,6 +323,7 @@ defmodule Hex.Registry.Server do
# {{:outer_checksum, ^repo, _package, _version}, _} -> true
# {{:retired, ^repo, _package, _version}, _} -> true
# {{:advisories, ^repo, _package, _version}, _} -> true
# {{:published_at, ^repo, _package, _version}, _} -> true
# {{:registry_etag, ^repo, _package}, _} -> true
# {{:timestamp, ^repo, _package}, _} -> true
# {{:timestamp, ^repo, _package, _version}, _} -> true
Expand All @@ -327,6 +338,7 @@ defmodule Hex.Registry.Server do
{{{:outer_checksum, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
{{{:retired, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
{{{:advisories, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
{{{:published_at, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
{{{:registry_etag, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
{{{:timestamp, :"$1", :"$2"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
{{{:timestamp, :"$1", :"$2", :"$3"}, :_}, [{:"=:=", {:const, repo}, :"$1"}], [true]},
Expand Down Expand Up @@ -388,6 +400,14 @@ defmodule Hex.Registry.Server do
:ets.insert(tid, {{:outer_checksum, repo, package, version}, release[:outer_checksum]})
:ets.insert(tid, {{:retired, repo, package, version}, release[:retired]})

# The registry encodes published_at as a {seconds, nanos} Timestamp
# map. Cooldown only needs second granularity, so store the integer
# to keep the consumers simple.
:ets.insert(
tid,
{{:published_at, repo, package, version}, timestamp_seconds(release[:published_at])}
)

release_advisories =
(release[:advisory_indexes] || [])
|> Enum.map(&Enum.at(pkg_advisories, &1))
Expand Down Expand Up @@ -539,6 +559,7 @@ defmodule Hex.Registry.Server do
:ets.delete(tid, {:checksum, repo, package, version})
:ets.delete(tid, {:retired, repo, package, version})
:ets.delete(tid, {:advisories, repo, package, version})
:ets.delete(tid, {:published_at, repo, package, version})
:ets.delete(tid, {:deps, repo, package, version})
end)
end
Expand All @@ -549,4 +570,7 @@ defmodule Hex.Registry.Server do
[] -> nil
end
end

defp timestamp_seconds(nil), do: nil
defp timestamp_seconds(%{seconds: seconds}), do: seconds
end
Loading
Loading