diff --git a/lib/tool_kit/cache.ex b/lib/tool_kit/cache.ex new file mode 100644 index 0000000..7824967 --- /dev/null +++ b/lib/tool_kit/cache.ex @@ -0,0 +1,322 @@ +defmodule ToolKit.Cache do + @moduledoc """ + TTL 付きファイルキャッシュ(機構のみ)。 + + (cache_dir, category, TTL)でパラメータ化した 2 層の API を提供する。 + キャッシュの用途(何をどのカテゴリに何秒入れるか)はツール側の責務。 + + - 低レベル API — `put/3` / `get/2` / `delete/2` / `refresh/2` / `clear/1` / + `status/2` / `stats/1`。エントリごとに JSON エンベロープ + (`key` / `cached_at` / `expires_at` / `data`)を + `//.json` へ保存し、`expires_at` で期限判定する + - ergonomic API — `get_or_fetch/3`。生バイナリを + `//` へ保存し、ファイル mtime で期限判定する。 + `ttl <= 0` は常にミス(`--no-cache` の実装手段) + + キャッシュは best-effort であり、ディレクトリ作成やファイル I/O の失敗で + 呼び出し元の処理を止めない(`put/3` はエラーをタプルで報告するが raise + しない。`get_or_fetch/3` は保存失敗を無視して取得結果を返す)。 + 書き込みは一時ファイル + rename のアトミック書き込みで、中断や並行実行で + 書きかけの内容がフレッシュなキャッシュとして残るのを防ぐ。 + + ## オプション + + - `:cache_dir` — キャッシュディレクトリ(必須) + - `:category` — カテゴリ = サブディレクトリ名(デフォルト `"default"`) + - `:ttl` — TTL 秒(デフォルト 3600) + + キーは `[^A-Za-z0-9._-]` を `_` に置換してフラットなファイル名へ落とすため、 + `owner/repo` のようなリポジトリ名をそのままキーにできる。 + """ + + @default_category "default" + @default_ttl 3600 + + @empty_stats %{total_entries: 0, total_size_bytes: 0, expired_entries: 0, valid_entries: 0} + + defmodule Status do + @moduledoc """ + キャッシュエントリ 1 件の状態。 + """ + defstruct [:key, :cached_at, :expires_at, exists: false, expired: false, size_bytes: 0] + + @type t :: %__MODULE__{ + key: String.t(), + exists: boolean(), + expired: boolean(), + cached_at: String.t() | nil, + expires_at: String.t() | nil, + size_bytes: non_neg_integer() + } + end + + @typedoc "全エントリの集計(`stats/1` の戻り値)" + @type stats :: %{ + total_entries: non_neg_integer(), + total_size_bytes: non_neg_integer(), + expired_entries: non_neg_integer(), + valid_entries: non_neg_integer() + } + + @doc """ + 低レベル API のキャッシュファイルパスを返す。 + + `//<サニタイズ済み key>.json`。 + """ + @spec cache_path(String.t(), keyword()) :: String.t() + def cache_path(key, opts) do + raw_path(key, opts) <> ".json" + end + + @doc """ + データを TTL 付きで保存する。 + + JSON エンベロープをアトミックに書き込む。失敗しても raise せず + `{:error, {:json_encode_failed | :write_failed, reason}}` を返す。 + """ + @spec put(String.t(), term(), keyword()) :: :ok | {:error, term()} + def put(key, data, opts) do + now = DateTime.utc_now() + + envelope = %{ + "key" => key, + "cached_at" => DateTime.to_iso8601(now), + "expires_at" => now |> expires_at(ttl(opts)) |> DateTime.to_iso8601(), + "data" => data + } + + with {:ok, json} <- encode(envelope) do + atomic_write(cache_path(key, opts), json) + end + end + + @doc """ + 保存済みデータを取り出す。 + + 有効なら `{:ok, data}`、それ以外は + `{:error, :cache_miss | :cache_expired | :invalid_cache | :read_failed}`。 + """ + @spec get(String.t(), keyword()) :: + {:ok, term()} | {:error, :cache_miss | :cache_expired | :invalid_cache | :read_failed} + def get(key, opts) do + case File.read(cache_path(key, opts)) do + {:ok, content} -> decode_and_validate(content) + {:error, :enoent} -> {:error, :cache_miss} + {:error, _reason} -> {:error, :read_failed} + end + end + + @doc """ + エントリを削除する。存在しなくても `:ok`。 + """ + @spec delete(String.t(), keyword()) :: :ok + def delete(key, opts) do + _ = File.rm(cache_path(key, opts)) + :ok + end + + @doc """ + エントリを破棄して次回アクセス時の再取得を強制する(`delete/2` と同義)。 + """ + @spec refresh(String.t(), keyword()) :: :ok + def refresh(key, opts), do: delete(key, opts) + + @doc """ + カテゴリ配下の全エントリを削除する。 + """ + @spec clear(keyword()) :: :ok + def clear(opts) do + _ = File.rm_rf(category_dir(opts)) + :ok + end + + @doc """ + エントリ 1 件の状態(存在・期限・サイズ・タイムスタンプ)を返す。 + + エンベロープを読めないエントリは `expired: true` として扱う。 + """ + @spec status(String.t(), keyword()) :: Status.t() + def status(key, opts) do + path = cache_path(key, opts) + + case File.stat(path) do + {:ok, %File.Stat{size: size}} -> existing_status(key, path, size) + {:error, _reason} -> %Status{key: key} + end + end + + @doc """ + カテゴリ配下の全エントリ(`*.json`)の集計を返す。 + """ + @spec stats(keyword()) :: stats() + def stats(opts) do + dir = category_dir(opts) + + case File.ls(dir) do + {:ok, files} -> + files + |> Enum.filter(&String.ends_with?(&1, ".json")) + |> Enum.reduce(@empty_stats, &accumulate_stats(Path.join(dir, &1), &2)) + + {:error, _reason} -> + @empty_stats + end + end + + @doc """ + ISO 8601 の `expires_at` が期限切れかを判定する。 + + `nil` やパースできない値は期限切れとして扱う。 + """ + @spec expired?(String.t() | nil) :: boolean() + def expired?(nil), do: true + + def expired?(expires_at_string) do + case DateTime.from_iso8601(expires_at_string) do + {:ok, expires_at, _offset} -> DateTime.compare(DateTime.utc_now(), expires_at) == :gt + {:error, _reason} -> true + end + end + + @doc """ + key のキャッシュが TTL 内ならその内容を返し、無ければ `fetch_fn.()` を実行して + 結果が `{:ok, binary}` のときだけキャッシュへ保存して返す。 + + 期限はファイル mtime で判定し、`ttl <= 0` は常にミス。fetch の失敗や + binary 以外の成功値はキャッシュしない(次回の呼び出しで再試行される)。 + 保存の失敗は無視する(キャッシュは best-effort)。 + """ + @spec get_or_fetch(String.t(), (-> {:ok, binary()} | term()), keyword()) :: + {:ok, binary()} | term() + def get_or_fetch(key, fetch_fn, opts) do + path = raw_path(key, opts) + + case read_fresh(path, ttl(opts)) do + {:ok, content} -> {:ok, content} + :miss -> fetch_and_store(fetch_fn, path) + end + end + + # --- 内部関数 --- + + defp ttl(opts), do: Keyword.get(opts, :ttl, @default_ttl) + + defp category_dir(opts) do + Path.join( + Keyword.fetch!(opts, :cache_dir), + Keyword.get(opts, :category, @default_category) + ) + end + + defp raw_path(key, opts), do: Path.join(category_dir(opts), sanitize(key)) + + # キーに含まれる repo/path 区切りをフラットなファイル名に落とす + defp sanitize(key), do: String.replace(key, ~r/[^A-Za-z0-9._-]/, "_") + + # round により小数 TTL(例: 0.5 秒)も秒精度で扱える + defp expires_at(now, ttl), do: DateTime.add(now, round(ttl), :second) + + defp encode(envelope) do + case Jason.encode(envelope, pretty: true) do + {:ok, json} -> {:ok, json} + {:error, reason} -> {:error, {:json_encode_failed, reason}} + end + end + + # 一時ファイル + rename のアトミック書き込み + defp atomic_write(path, content) do + tmp = "#{path}.tmp.#{:erlang.unique_integer([:positive])}" + + with :ok <- File.mkdir_p(Path.dirname(path)), + :ok <- File.write(tmp, content), + :ok <- File.rename(tmp, path) do + :ok + else + {:error, reason} -> + # 後始末の失敗(tmp 未作成の :enoent 等)は無視する + _ = File.rm(tmp) + {:error, {:write_failed, reason}} + end + end + + defp decode_and_validate(content) do + case Jason.decode(content) do + {:ok, envelope} -> + if expired?(envelope["expires_at"]) do + {:error, :cache_expired} + else + {:ok, envelope["data"]} + end + + {:error, _reason} -> + {:error, :invalid_cache} + end + end + + defp existing_status(key, path, size) do + base = %Status{key: key, exists: true, expired: true, size_bytes: size} + + with {:ok, content} <- File.read(path), + {:ok, envelope} <- Jason.decode(content) do + %{ + base + | expired: expired?(envelope["expires_at"]), + cached_at: envelope["cached_at"], + expires_at: envelope["expires_at"] + } + else + _ -> base + end + end + + defp accumulate_stats(path, acc) do + case File.stat(path) do + {:ok, %File.Stat{size: size}} -> + expired = file_expired?(path) + + %{ + total_entries: acc.total_entries + 1, + total_size_bytes: acc.total_size_bytes + size, + expired_entries: acc.expired_entries + if(expired, do: 1, else: 0), + valid_entries: acc.valid_entries + if(expired, do: 0, else: 1) + } + + {:error, _reason} -> + acc + end + end + + defp file_expired?(path) do + with {:ok, content} <- File.read(path), + {:ok, envelope} <- Jason.decode(content) do + expired?(envelope["expires_at"]) + else + _ -> true + end + end + + defp read_fresh(path, ttl) when is_number(ttl) and ttl > 0 do + now = System.system_time(:second) + + with {:ok, %File.Stat{mtime: mtime}} <- File.stat(path, time: :posix), + true <- now - mtime < ttl, + {:ok, content} <- File.read(path) do + {:ok, content} + else + _ -> :miss + end + end + + defp read_fresh(_path, _ttl), do: :miss + + defp fetch_and_store(fetch_fn, path) do + case fetch_fn.() do + {:ok, content} = ok when is_binary(content) -> + _ = atomic_write(path, content) + ok + + other -> + other + end + end +end diff --git a/test/tool_kit/cache_test.exs b/test/tool_kit/cache_test.exs new file mode 100644 index 0000000..bdacebd --- /dev/null +++ b/test/tool_kit/cache_test.exs @@ -0,0 +1,322 @@ +defmodule ToolKit.CacheTest do + use ExUnit.Case, async: true + + alias ToolKit.Cache + alias ToolKit.Cache.Status + + # 呼び出しを self() へのメッセージで数える fetch 関数 + defp counting_fetch(result) do + fn -> + send(self(), :fetched) + result + end + end + + describe "cache_path/2" do + test "joins cache_dir / category / key with .json extension" do + assert Cache.cache_path("repo", cache_dir: "/c", category: "activity") == + "/c/activity/repo.json" + end + + test "defaults the category" do + assert Cache.cache_path("repo", cache_dir: "/c") == "/c/default/repo.json" + end + + test "sanitizes keys into flat file names" do + assert Cache.cache_path("owner/repo name", cache_dir: "/c") == + "/c/default/owner_repo_name.json" + end + end + + describe "put/3 and get/2" do + @tag :tmp_dir + test "get returns the data stored by put", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + + assert Cache.put("repo", %{"stars" => 3}, opts) == :ok + assert Cache.get("repo", opts) == {:ok, %{"stars" => 3}} + end + + @tag :tmp_dir + test "get misses when nothing was stored", %{tmp_dir: tmp_dir} do + assert Cache.get("repo", cache_dir: tmp_dir) == {:error, :cache_miss} + end + + @tag :tmp_dir + test "get reports an expired entry", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + + assert Cache.put("repo", %{"stars" => 3}, Keyword.put(opts, :ttl, -1)) == :ok + assert Cache.get("repo", opts) == {:error, :cache_expired} + end + + @tag :tmp_dir + test "get reports a corrupted entry", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + path = Cache.cache_path("repo", opts) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "not json") + + assert Cache.get("repo", opts) == {:error, :invalid_cache} + end + + @tag :tmp_dir + test "categories are isolated", %{tmp_dir: tmp_dir} do + assert Cache.put("repo", %{"a" => 1}, cache_dir: tmp_dir, category: "activity") == :ok + + assert Cache.get("repo", cache_dir: tmp_dir, category: "pr-status") == + {:error, :cache_miss} + end + + @tag :tmp_dir + test "put writes a JSON envelope with metadata and leaves no temp files", + %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, category: "activity"] + + assert Cache.put("repo", %{"a" => 1}, opts) == :ok + + envelope = Cache.cache_path("repo", opts) |> File.read!() |> Jason.decode!() + assert envelope["key"] == "repo" + assert envelope["data"] == %{"a" => 1} + assert {:ok, _, _} = DateTime.from_iso8601(envelope["cached_at"]) + assert {:ok, _, _} = DateTime.from_iso8601(envelope["expires_at"]) + + assert File.ls!(Path.join(tmp_dir, "activity")) == ["repo.json"] + end + + @tag :tmp_dir + test "put reports unencodable data without raising", %{tmp_dir: tmp_dir} do + assert {:error, {:json_encode_failed, _}} = + Cache.put("repo", %{"pid" => self()}, cache_dir: tmp_dir) + end + + @tag :tmp_dir + test "put reports write failures without raising", %{tmp_dir: tmp_dir} do + not_a_dir = Path.join(tmp_dir, "not_a_dir") + File.write!(not_a_dir, "x") + + assert {:error, {:write_failed, _}} = + Cache.put("repo", %{"a" => 1}, cache_dir: not_a_dir) + end + end + + describe "delete/2 and refresh/2" do + @tag :tmp_dir + test "delete removes the entry", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + + assert Cache.put("repo", %{"a" => 1}, opts) == :ok + assert Cache.delete("repo", opts) == :ok + assert Cache.get("repo", opts) == {:error, :cache_miss} + end + + @tag :tmp_dir + test "delete is a no-op for a missing entry", %{tmp_dir: tmp_dir} do + assert Cache.delete("repo", cache_dir: tmp_dir) == :ok + end + + @tag :tmp_dir + test "refresh forces a re-fetch on next access", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + + assert Cache.put("repo", %{"a" => 1}, opts) == :ok + assert Cache.refresh("repo", opts) == :ok + assert Cache.get("repo", opts) == {:error, :cache_miss} + end + end + + describe "clear/1" do + @tag :tmp_dir + test "clears only its own category", %{tmp_dir: tmp_dir} do + assert Cache.put("repo", %{"a" => 1}, cache_dir: tmp_dir, category: "activity") == :ok + assert Cache.put("repo", %{"b" => 2}, cache_dir: tmp_dir, category: "pr-status") == :ok + + assert Cache.clear(cache_dir: tmp_dir, category: "activity") == :ok + + assert Cache.get("repo", cache_dir: tmp_dir, category: "activity") == + {:error, :cache_miss} + + assert Cache.get("repo", cache_dir: tmp_dir, category: "pr-status") == {:ok, %{"b" => 2}} + end + + @tag :tmp_dir + test "is a no-op when the category directory does not exist", %{tmp_dir: tmp_dir} do + assert Cache.clear(cache_dir: tmp_dir, category: "missing") == :ok + end + end + + describe "status/2" do + @tag :tmp_dir + test "reports a missing entry", %{tmp_dir: tmp_dir} do + assert Cache.status("repo", cache_dir: tmp_dir) == %Status{ + key: "repo", + exists: false, + expired: false, + cached_at: nil, + expires_at: nil, + size_bytes: 0 + } + end + + @tag :tmp_dir + test "reports a valid entry", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + assert Cache.put("repo", %{"a" => 1}, opts) == :ok + + status = Cache.status("repo", opts) + assert %Status{key: "repo", exists: true, expired: false} = status + assert {:ok, _, _} = DateTime.from_iso8601(status.cached_at) + assert {:ok, _, _} = DateTime.from_iso8601(status.expires_at) + assert status.size_bytes > 0 + end + + @tag :tmp_dir + test "reports an expired entry", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + assert Cache.put("repo", %{"a" => 1}, Keyword.put(opts, :ttl, -1)) == :ok + + assert %Status{exists: true, expired: true} = Cache.status("repo", opts) + end + + @tag :tmp_dir + test "reports a corrupted entry as expired", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + path = Cache.cache_path("repo", opts) + File.mkdir_p!(Path.dirname(path)) + File.write!(path, "not json") + + status = Cache.status("repo", opts) + assert %Status{exists: true, expired: true, cached_at: nil, expires_at: nil} = status + assert status.size_bytes > 0 + end + end + + describe "stats/1" do + @empty %{total_entries: 0, total_size_bytes: 0, expired_entries: 0, valid_entries: 0} + + @tag :tmp_dir + test "returns zeros when the category directory does not exist", %{tmp_dir: tmp_dir} do + assert Cache.stats(cache_dir: tmp_dir, category: "missing") == @empty + end + + @tag :tmp_dir + test "counts valid and expired entries, ignoring non-JSON files", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir] + + assert Cache.put("fresh-1", %{"a" => 1}, opts) == :ok + assert Cache.put("fresh-2", %{"b" => 2}, opts) == :ok + assert Cache.put("stale", %{"c" => 3}, Keyword.put(opts, :ttl, -1)) == :ok + File.write!(Path.join([tmp_dir, "default", "raw-binary"]), "raw") + + stats = Cache.stats(opts) + assert stats.total_entries == 3 + assert stats.valid_entries == 2 + assert stats.expired_entries == 1 + assert stats.total_size_bytes > 0 + end + end + + describe "expired?/1" do + test "nil is expired" do + assert Cache.expired?(nil) + end + + test "an unparsable timestamp is expired" do + assert Cache.expired?("not-a-date") + end + + test "a past timestamp is expired" do + assert Cache.expired?("2020-01-01T00:00:00Z") + end + + test "a future timestamp is not expired" do + future = DateTime.utc_now() |> DateTime.add(3600, :second) |> DateTime.to_iso8601() + refute Cache.expired?(future) + end + end + + describe "get_or_fetch/3" do + @tag :tmp_dir + test "fetches on miss and serves the cached copy afterwards", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, ttl: 60] + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "body"}), opts) == {:ok, "body"} + assert_received :fetched + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "other"}), opts) == {:ok, "body"} + refute_received :fetched + end + + @tag :tmp_dir + test "re-fetches once the file mtime falls outside the TTL", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, ttl: 60] + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "old"}), opts) == {:ok, "old"} + File.touch!(Path.join([tmp_dir, "default", "key"]), System.os_time(:second) - 120) + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "new"}), opts) == {:ok, "new"} + assert_received :fetched + assert_received :fetched + end + + @tag :tmp_dir + test "ttl <= 0 always misses (--no-cache)", %{tmp_dir: tmp_dir} do + for ttl <- [0, -1] do + opts = [cache_dir: tmp_dir, category: "ttl#{ttl}", ttl: ttl] + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "a"}), opts) == {:ok, "a"} + assert Cache.get_or_fetch("key", counting_fetch({:ok, "b"}), opts) == {:ok, "b"} + assert_received :fetched + assert_received :fetched + end + end + + @tag :tmp_dir + test "passes fetch errors through without caching them", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, ttl: 60] + + assert Cache.get_or_fetch("key", fn -> {:error, :boom} end, opts) == {:error, :boom} + refute File.exists?(Path.join([tmp_dir, "default", "key"])) + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "body"}), opts) == {:ok, "body"} + assert_received :fetched + end + + @tag :tmp_dir + test "returns non-binary :ok results without caching them", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, ttl: 60] + + assert Cache.get_or_fetch("key", fn -> {:ok, %{"a" => 1}} end, opts) == {:ok, %{"a" => 1}} + refute File.exists?(Path.join([tmp_dir, "default", "key"])) + end + + @tag :tmp_dir + test "writes atomically: only the final file remains", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, ttl: 60] + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "body"}), opts) == {:ok, "body"} + + assert File.ls!(Path.join(tmp_dir, "default")) == ["key"] + assert File.read!(Path.join([tmp_dir, "default", "key"])) == "body" + end + + @tag :tmp_dir + test "sanitizes keys into flat file names", %{tmp_dir: tmp_dir} do + opts = [cache_dir: tmp_dir, ttl: 60] + + assert Cache.get_or_fetch("owner/repo", counting_fetch({:ok, "body"}), opts) == + {:ok, "body"} + + assert File.ls!(Path.join(tmp_dir, "default")) == ["owner_repo"] + end + + @tag :tmp_dir + test "still returns the fetched value when the cache is unwritable", %{tmp_dir: tmp_dir} do + not_a_dir = Path.join(tmp_dir, "not_a_dir") + File.write!(not_a_dir, "x") + opts = [cache_dir: not_a_dir, ttl: 60] + + assert Cache.get_or_fetch("key", counting_fetch({:ok, "body"}), opts) == {:ok, "body"} + end + end +end