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
322 changes: 322 additions & 0 deletions lib/tool_kit/cache.ex
Original file line number Diff line number Diff line change
@@ -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`)を
`<cache_dir>/<category>/<key>.json` へ保存し、`expires_at` で期限判定する
- ergonomic API — `get_or_fetch/3`。生バイナリを
`<cache_dir>/<category>/<key>` へ保存し、ファイル 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 のキャッシュファイルパスを返す。

`<cache_dir>/<category>/<サニタイズ済み 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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] [LOW] put/3ttl に負値を渡すと過去の expires_at を持つエントリが書き込まれる

テストでも ttl: -1 を使って期限切れエントリを作成していますが、これは内部テスト用の便宜的な使い方です。公開 API として負の TTL を渡した場合の挙動をドキュメントに明記するか、put/3 側でガード(ttl >= 0)を設けることを検討してください。get_or_fetch/3 では ttl <= 0 を常にミスとして扱っているのに対し、put/3 では制限がなく、API の一貫性が欠けています。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

put/3 に負の TTL を渡して「即時期限切れのエントリ」を書けるのは意図的な仕様です(テストの期限切れケース生成にも利用)。get_or_fetch/3ttl <= 0 は「読まない=--no-cache」という別の意味論であり、両者は役割が異なります。put 側にガードを入れると期限切れエントリの明示生成ができなくなるため、ガードは設けず据え置きます。moduledoc の TTL 説明で意味論は示していますが、負値挙動の明記は将来の doc 改善として検討します。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] [LOW] expired?/1 の境界条件: DateTime.compare:eq のとき有効扱いになる

DateTime.compare(DateTime.utc_now(), expires_at) == :gt

これは now == expires_at(:eq)のとき false(有効)を返します。TTL の意味上、期限ちょうどは「期限切れ」とする方が自然な場合が多いです。意図的な設計であればドキュメントに明記することを推奨します。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

:eq(期限ちょうど)を有効扱いにするのは移植元 registry-manager の compare(...) == :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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ℹ️ [LOW] [LOW] atomic_write/2 のエラー分岐で reason が最初のエラーのみを捕捉している

withelse 節では mkdir_pFile.writeFile.rename のいずれかのエラーが reason に束縛されますが、どのステップで失敗したかが {:write_failed, reason} からは判別できません。デバッグ性を高めるために、ステップを区別したエラータグ(例: {:write_failed, :mkdir, reason})を検討してください。現状の best-effort 設計では許容範囲ですが、将来の診断のために記録しておく価値があります。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ステップ別エラータグは診断性を上げますが、移植元 2 ツールとも {:write_failed, reason} 単一タグで、キャッシュ失敗は best-effort として本体処理に影響しない設計です。reason(:enoent / :eacces 等)から失敗箇所はおおむね推測でき、公開 API のエラー形を移植元と揃える利点を優先して据え置きます。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] [MEDIUM] status/2stats/1 でファイルを二重読み込みしている

status/2existing_status/3 では File.stat でサイズを取得した後、File.read でファイルを再度読み込んでいます。同様に accumulate_stats/2 でも File.statfile_expired?/1(内部で File.read)を別々に呼んでいます。

現状の best-effort 設計では許容範囲ですが、ファイル数が多い場合のパフォーマンスと TOCTOU(Time-of-check-time-of-use)の観点から、File.read 一本で読んでから byte_size/1 でサイズを取得する方が望ましいです。

# 例: accumulate_stats
defp accumulate_stats(path, acc) do
  with {:ok, content} <- File.read(path),
       {:ok, envelope} <- Jason.decode(content) do
    expired = expired?(envelope["expires_at"])
    %{
      total_entries: acc.total_entries + 1,
      total_size_bytes: acc.total_size_bytes + byte_size(content),
      expired_entries: acc.expired_entries + if(expired, do: 1, else: 0),
      valid_entries: acc.valid_entries + if(expired, do: 0, else: 1)
    }
  else
    _ -> acc
  end
end

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ご指摘のとおり File.read 一本 + byte_size/1 の方が I/O は減ります。ただし現状は移植元 registry-manager の stat 基準の統計挙動(読めない .json も size 付きで exists/expired として計上)を保つ意図があり、ご自身も「best-effort 設計では許容範囲」と評価されているため、本 PR では据え置きます。実運用でのファイル数増加が問題になった段階で単一読み込みへ寄せる方針とします。

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ [MEDIUM] [MEDIUM] read_fresh/2 に TOCTOU 競合がある

File.stat で mtime を確認してから File.read するまでの間に別プロセスがファイルを更新する可能性があります。get_or_fetch/3 は best-effort とはいえ、File.readFile.stat の順序(読んでから mtime を確認)にすれば、少なくとも「読んだ内容が TTL 内のものか」を保証できます。

defp read_fresh(path, ttl) when is_number(ttl) and ttl > 0 do
  now = System.system_time(:second)

  with {:ok, content} <- File.read(path),
       {:ok, %File.Stat{mtime: mtime}} <- File.stat(path, time: :posix),
       true <- now - mtime < ttl do
    {:ok, content}
  else
    _ -> :miss
  end
end

ただし完全な解決には atomic read が必要なため、best-effort の範囲内での改善として検討してください。

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

この順序は移植元 thesis-monitor(File.statFile.read)と揃えています。ご提案の read→stat 版もウィンドウを別の箇所に移すだけで、ご指摘どおり完全解消には atomic read が必要です。get_or_fetch は best-effort(古い内容を掴んでも「かつてフレッシュだった内容」であり無害)であり、両順序で TOCTOU リスクは実質同等のため、移植元とのパリティを優先して据え置きます。

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
Loading
Loading