6.0 KiB · text 5af55d9
defmodule GitGud.Workflows.ActionsCache do
@moduledoc """
Backend for the GitHub Actions cache server (the v1 protocol the
`actions/cache` action speaks). Persists per-repo cache entries
keyed on `(key, version, ref)`.
## Lookup semantics
The runner sends `keys=k1,k2,...,kN` + `version=V`. We resolve hits
in this order, per the upstream spec:
1. Exact match on `k1` in the current ref
2. Exact match on `k1` in the repo's default ref
3. For each `k2..kN` (prefix match), most-recent-first:
a. Current ref
b. Default ref
We only return finalized entries (`archived_at IS NOT NULL`).
Pending reservations are invisible to lookup.
## Storage
Object bytes live under `GitGud.Storage` bucket `gitgud-cache` at
`<repo_id>/<row_id>`. The download URL handed to the runner is a
short-lived presigned URL — for the Disk backend this routes
through `StorageController` with a token, for Garage it's a real
S3 presigned URL. Same primitive either way.
"""
import Ecto.Query, warn: false
alias GitGud.Repo
alias GitGud.Repositories.Repository
alias GitGud.Storage
alias GitGud.Workflows.CacheEntry
@bucket "gitgud-cache"
@download_expiry 300
@upload_expiry 600
def bucket, do: @bucket
# ── lookup ──────────────────────────────────────────────────────────
@type hit :: %{entry: CacheEntry.t(), matched_key: String.t()}
@doc """
Resolve a list of cache keys against the repo + version. Returns
the first matching `%CacheEntry{}` or `nil` if nothing matches.
`default_ref` should be `"refs/heads/<default_branch>"`; pass `nil`
to disable fallback to the default branch.
"""
@spec lookup(integer(), String.t(), [String.t()], String.t() | nil, String.t() | nil) ::
hit() | nil
def lookup(_repo_id, _version, [], _ref, _default_ref), do: nil
def lookup(repo_id, version, [primary | fallbacks], ref, default_ref) do
refs = [ref, default_ref] |> Enum.reject(&is_nil/1) |> Enum.uniq()
result =
# 1. Exact primary on each ref in priority order.
Enum.find_value(refs, &exact_match(repo_id, version, primary, &1)) ||
# 2. Prefix fallbacks across each ref.
Enum.find_value(fallbacks, fn key ->
Enum.find_value(refs, &prefix_match(repo_id, version, key, &1))
end)
case result do
%CacheEntry{} = entry ->
# Bump last_used_at so an eventual eviction worker can do LRU.
_ = entry |> CacheEntry.touch_changeset() |> Repo.update()
%{entry: entry, matched_key: entry.key}
_ ->
nil
end
end
defp exact_match(repo_id, version, key, ref) do
Repo.one(
from c in CacheEntry,
where:
c.repository_id == ^repo_id and
c.version == ^version and
c.key == ^key and
c.ref == ^ref and
not is_nil(c.archived_at),
limit: 1
)
end
defp prefix_match(repo_id, version, prefix, ref) do
Repo.one(
from c in CacheEntry,
where:
c.repository_id == ^repo_id and
c.version == ^version and
like(c.key, ^(escape_like(prefix) <> "%")) and
c.ref == ^ref and
not is_nil(c.archived_at),
order_by: [desc: c.archived_at],
limit: 1
)
end
defp escape_like(s) do
s
|> String.replace("\\", "\\\\")
|> String.replace("%", "\\%")
|> String.replace("_", "\\_")
end
# ── reserve / finalize ──────────────────────────────────────────────
@doc """
Reserve a cache row for an in-flight upload. Upsert on the
`(repo, key, version, ref)` unique key — re-uploading the same
triple replaces the prior row's storage_key and clears the
archived_at, so lookups won't return an outdated entry mid-upload.
"""
def reserve(%Repository{id: rid}, key, version, ref) do
{:ok, row} =
Repo.insert(
%CacheEntry{}
|> CacheEntry.reserve_changeset(%{
repository_id: rid,
key: key,
version: version,
ref: ref,
# We'll patch storage_key with the real value once we know the row id.
storage_key: "pending"
}),
on_conflict: [
set: [
storage_key: "pending",
archived_at: nil,
size: nil,
updated_at: DateTime.utc_now(:second)
]
],
conflict_target: [:repository_id, :key, :version, :ref],
returning: true
)
real_key = storage_key_for(rid, row.id)
{:ok, row} =
row
|> Ecto.Changeset.change(storage_key: real_key)
|> Repo.update()
{:ok, row}
end
defp storage_key_for(repo_id, row_id),
do: "#{repo_id}/#{row_id}"
def get!(id), do: Repo.get!(CacheEntry, id)
@doc """
Mark a row finalized + record its size. Called from the controller
after the bytes are committed to Storage.
"""
def finalize(%CacheEntry{} = entry, size) when is_integer(size) do
entry |> CacheEntry.finalize_changeset(size) |> Repo.update()
end
@doc """
Append `body` to the row's storage object. The cache action issues
multiple PATCHes with `Content-Range`; we concatenate via a simple
read-modify-write because the cache objects are bounded (≤ few hundred
MB) and writes are runner-serial.
"""
def append(%CacheEntry{} = entry, body) when is_binary(body) do
previous =
case Storage.get(@bucket, entry.storage_key) do
{:ok, bin} -> bin
_ -> ""
end
Storage.put(@bucket, entry.storage_key, previous <> body, [])
end
@doc """
Short-lived URL the runner can GET to pull cached bytes. Returns
`{:ok, url}` or `{:error, reason}`.
"""
def download_url(%CacheEntry{} = entry) do
Storage.presign_url(@bucket, entry.storage_key, :get, expires_in: @download_expiry)
end
def upload_expiry, do: @upload_expiry
def download_expiry, do: @download_expiry
end