feat: add TTL file cache with get_or_fetch (ToolKit.Cache) - #16
Conversation
Port registry-manager's category/JSON-envelope cache and thesis-monitor's
mtime-based get_or_fetch into a single (cache_dir, category, ttl)
parameterized module.
- Low-level API: put/get/delete/refresh/clear/status/stats over a JSON
envelope (key/cached_at/expires_at/data) at
<cache_dir>/<category>/<key>.json, expiry judged by expires_at.
- Ergonomic API: get_or_fetch/3 stores raw binaries at
<cache_dir>/<category>/<key>, expiry judged by file mtime; ttl <= 0
always misses (--no-cache). Only {:ok, binary} is cached.
- Cache I/O is best-effort: directory/write failures never break the
caller. Writes are atomic (temp file + rename).
Resolves #6
| end | ||
| end | ||
|
|
||
| defp accumulate_stats(path, acc) do |
There was a problem hiding this comment.
status/2 と stats/1 でファイルを二重読み込みしている
status/2 → existing_status/3 では File.stat でサイズを取得した後、File.read でファイルを再度読み込んでいます。同様に accumulate_stats/2 でも File.stat と file_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
endThere was a problem hiding this comment.
ご指摘のとおり File.read 一本 + byte_size/1 の方が I/O は減ります。ただし現状は移植元 registry-manager の stat 基準の統計挙動(読めない .json も size 付きで exists/expired として計上)を保つ意図があり、ご自身も「best-effort 設計では許容範囲」と評価されているため、本 PR では据え置きます。実運用でのファイル数増加が問題になった段階で単一読み込みへ寄せる方針とします。
| end | ||
| end | ||
|
|
||
| defp read_fresh(path, ttl) when is_number(ttl) and ttl > 0 do |
There was a problem hiding this comment.
read_fresh/2 に TOCTOU 競合がある
File.stat で mtime を確認してから File.read するまでの間に別プロセスがファイルを更新する可能性があります。get_or_fetch/3 は best-effort とはいえ、File.read → File.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 の範囲内での改善として検討してください。
There was a problem hiding this comment.
この順序は移植元 thesis-monitor(File.stat → File.read)と揃えています。ご提案の read→stat 版もウィンドウを別の箇所に移すだけで、ご指摘どおり完全解消には atomic read が必要です。get_or_fetch は best-effort(古い内容を掴んでも「かつてフレッシュだった内容」であり無害)であり、両順序で TOCTOU リスクは実質同等のため、移植元とのパリティを優先して据え置きます。
|
|
||
| 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 |
There was a problem hiding this comment.
ℹ️ [LOW] [LOW] expired?/1 の境界条件: DateTime.compare が :eq のとき有効扱いになる
DateTime.compare(DateTime.utc_now(), expires_at) == :gtこれは now == expires_at(:eq)のとき false(有効)を返します。TTL の意味上、期限ちょうどは「期限切れ」とする方が自然な場合が多いです。意図的な設計であればドキュメントに明記することを推奨します。
There was a problem hiding this comment.
:eq(期限ちょうど)を有効扱いにするのは移植元 registry-manager の compare(...) == :gt 挙動をそのまま保っています。秒精度の境界が一致する確率は実運用上ほぼ無視でき、両ツールの既存挙動と一致させることを優先して据え置きます。
| `{:error, {:json_encode_failed | :write_failed, reason}}` を返す。 | ||
| """ | ||
| @spec put(String.t(), term(), keyword()) :: :ok | {:error, term()} | ||
| def put(key, data, opts) do |
There was a problem hiding this comment.
ℹ️ [LOW] [LOW] put/3 の ttl に負値を渡すと過去の expires_at を持つエントリが書き込まれる
テストでも ttl: -1 を使って期限切れエントリを作成していますが、これは内部テスト用の便宜的な使い方です。公開 API として負の TTL を渡した場合の挙動をドキュメントに明記するか、put/3 側でガード(ttl >= 0)を設けることを検討してください。get_or_fetch/3 では ttl <= 0 を常にミスとして扱っているのに対し、put/3 では制限がなく、API の一貫性が欠けています。
There was a problem hiding this comment.
put/3 に負の TTL を渡して「即時期限切れのエントリ」を書けるのは意図的な仕様です(テストの期限切れケース生成にも利用)。get_or_fetch/3 の ttl <= 0 は「読まない=--no-cache」という別の意味論であり、両者は役割が異なります。put 側にガードを入れると期限切れエントリの明示生成ができなくなるため、ガードは設けず据え置きます。moduledoc の TTL 説明で意味論は示していますが、負値挙動の明記は将来の doc 改善として検討します。
| end | ||
|
|
||
| # 一時ファイル + rename のアトミック書き込み | ||
| defp atomic_write(path, content) do |
There was a problem hiding this comment.
ℹ️ [LOW] [LOW] atomic_write/2 のエラー分岐で reason が最初のエラーのみを捕捉している
with の else 節では mkdir_p・File.write・File.rename のいずれかのエラーが reason に束縛されますが、どのステップで失敗したかが {:write_failed, reason} からは判別できません。デバッグ性を高めるために、ステップを区別したエラータグ(例: {:write_failed, :mkdir, reason})を検討してください。現状の best-effort 設計では許容範囲ですが、将来の診断のために記録しておく価値があります。
There was a problem hiding this comment.
ステップ別エラータグは診断性を上げますが、移植元 2 ツールとも {:write_failed, reason} 単一タグで、キャッシュ失敗は best-effort として本体処理に影響しない設計です。reason(:enoent / :eacces 等)から失敗箇所はおおむね推測でき、公開 API のエラー形を移植元と揃える利点を優先して据え置きます。
| alias ToolKit.Cache.Status | ||
|
|
||
| # 呼び出しを self() へのメッセージで数える fetch 関数 | ||
| defp counting_fetch(result) do |
There was a problem hiding this comment.
✨ [POSITIVE] [POSITIVE] counting_fetch/1 ヘルパーによるテスト設計が優れている
send(self(), :fetched) を使って fetch 呼び出し回数を assert_received/refute_received で検証するパターンは、モックライブラリ不要でキャッシュヒット/ミスの挙動を明確に検証できており、非常に読みやすいテスト設計です。
AI レビュー対応まとめ指摘 6 件(MEDIUM 2 / LOW 3 / POSITIVE 1)を確認しました。実バグはなく、いずれも移植元(registry-manager / thesis-monitor)の挙動保存・best-effort 方針との整合を理由に据え置き、各スレッドに根拠を返信しました(push なし)。
品質ゲートは全 green(format / credo --strict / dialyzer / test 194 tests 0 failures、ToolKit.Cache 97.14% / 全体 93.58%、閾値 80 以上)。CI(Code Quality / Test on 1.17.3 / 1.20.1 / review)も success。 Resolves #6 |
概要
registry-manager の
cache.ex(カテゴリ別 JSON ファイル + metadata、TTL、status/clear/refreshプリミティブ、CacheStatus 相当の統計)と thesis-monitor のcache.ex(mtime ベース TTL のget_or_fetch/3、ttl <= 0は常にミス、{:ok, binary}のみ tmp+rename の atomic write)を、(cache_dir, category, TTL) でパラメータ化した上位集合ToolKit.Cacheに統合しました。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で期限判定。get_or_fetch/3。生バイナリを<cache_dir>/<category>/<key>に保存し、ファイル mtime で期限判定。ttl <= 0は常にミス(--no-cacheの実装手段)。{:ok, binary}のみキャッシュ。方針(両ツール共通)
put/3はエラーをタプルで報告し raise しない、get_or_fetch/3は保存失敗を無視して取得結果を返す)。テスト
@tag :tmp_dirで実 FS を使用し、ヒット/ミス/期限切れ/corrupted/clear/refresh/ttl<=0/atomic write/best-effort をカバー。ToolKit.Cacheは 97.14%、全体 93.58%(閾値 80 以上)。品質ゲート
mix format --check-formatted/mix credo --strict/mix dialyzer/mix testすべて green。Resolves #6
親 epic: smkwlab/latex-ecosystem#146