defmodule GitGud.Workflows.Workers.CacheEviction do
@moduledoc """
Periodic pruner for `actions/cache` entries.
Two independently configurable knobs:
config :git_gud, GitGud.Workflows.Workers.CacheEviction,
max_age_days: 14,
per_repo_max_mb: 5_000,
dry_run: false
* `:max_age_days` — drop entries whose `last_used_at` (falling back
to `archived_at` if never read) is older than N days. `nil` to
disable age-based pruning.
* `:per_repo_max_mb` — for each repo, after age pruning, keep total
finalized cache bytes under this limit. Excess is evicted LRU by
`last_used_at`. `nil` to disable.
* `:dry_run` — when true, computes what would be evicted but
neither deletes rows nor touches storage. Useful for piloting
changes to the knobs.
Storage objects are deleted before the row, so an interruption mid-prune
leaves stranded blobs (cheap) rather than DB rows pointing at missing
objects (which would fail lookups).
Schedule via Oban Cron in `config/config.exs`:
{Oban.Plugins.Cron,
crontab: [
{"@hourly", GitGud.Workflows.Workers.CacheEviction}
]}
"""
use Oban.Worker, queue: :default, max_attempts: 3
require Logger
import Ecto.Query, warn: false
alias GitGud.Repo
alias GitGud.Storage
alias GitGud.Workflows.ActionsCache
alias GitGud.Workflows.CacheEntry
@impl Oban.Worker
def perform(_job) do
cfg = Application.get_env(:git_gud, __MODULE__, [])
dry_run? = Keyword.get(cfg, :dry_run, false)
age_count =
prune_by_age(Keyword.get(cfg, :max_age_days), dry_run?)
size_count =
prune_by_size(Keyword.get(cfg, :per_repo_max_mb), dry_run?)
if age_count + size_count > 0 do
Logger.info(
"cache eviction: age=#{age_count} size=#{size_count}#{if dry_run?, do: " (dry-run)", else: ""}"
)
end
:ok
end
# ── age-based ───────────────────────────────────────────────────────
defp prune_by_age(nil, _), do: 0
defp prune_by_age(n, _) when not is_integer(n) or n <= 0, do: 0
defp prune_by_age(days, dry_run?) do
cutoff = DateTime.utc_now() |> DateTime.add(-days * 86_400, :second)
stale =
Repo.all(
from c in CacheEntry,
# `last_used_at` is bumped on every hit; if it's never been
# read (last_used_at is nil), fall back to archived_at so a
# never-used upload doesn't live forever.
where: coalesce(c.last_used_at, c.archived_at) < ^cutoff,
select: c
)
evict(stale, dry_run?)
end
# ── size-based (per repo, LRU) ──────────────────────────────────────
defp prune_by_size(nil, _), do: 0
defp prune_by_size(n, _) when not is_integer(n) or n <= 0, do: 0
defp prune_by_size(max_mb, dry_run?) do
cap_bytes = max_mb * 1024 * 1024
repo_ids =
Repo.all(
from c in CacheEntry,
where: not is_nil(c.archived_at),
group_by: c.repository_id,
having: sum(coalesce(c.size, 0)) > ^cap_bytes,
select: c.repository_id
)
Enum.reduce(repo_ids, 0, fn rid, acc -> acc + prune_repo_size(rid, cap_bytes, dry_run?) end)
end
defp prune_repo_size(repo_id, cap_bytes, dry_run?) do
# Walk newest-used → oldest, keep entries while running total under
# cap. The tail past the cap is the eviction set.
entries =
Repo.all(
from c in CacheEntry,
where: c.repository_id == ^repo_id and not is_nil(c.archived_at),
order_by: [desc: coalesce(c.last_used_at, c.archived_at)]
)
{_kept, evictable} =
Enum.reduce(entries, {0, []}, fn entry, {acc_size, evict_list} ->
size = entry.size || 0
new_total = acc_size + size
if new_total <= cap_bytes do
{new_total, evict_list}
else
{acc_size, [entry | evict_list]}
end
end)
evict(evictable, dry_run?)
end
# ── eviction ────────────────────────────────────────────────────────
defp evict(entries, true), do: length(entries)
defp evict(entries, false) do
Enum.each(entries, fn entry ->
_ = Storage.delete(ActionsCache.bucket(), entry.storage_key)
_ = Repo.delete(entry)
end)
length(entries)
end
@doc "Run the eviction pass synchronously (manual ops + tests)."
def run_now, do: perform(%Oban.Job{args: %{}})
end
4.6 KiB · text
5af55d9