Skip to content

feat: add TTL file cache with get_or_fetch (ToolKit.Cache) - #16

Merged
toshi0806 merged 1 commit into
mainfrom
issue-6-file-cache
Jul 23, 2026
Merged

feat: add TTL file cache with get_or_fetch (ToolKit.Cache)#16
toshi0806 merged 1 commit into
mainfrom
issue-6-file-cache

Conversation

@toshi0806

Copy link
Copy Markdown
Member

概要

registry-manager の cache.ex(カテゴリ別 JSON ファイル + metadata、TTL、status/clear/refresh プリミティブ、CacheStatus 相当の統計)と thesis-monitor の cache.ex(mtime ベース TTL の get_or_fetch/3ttl <= 0 は常にミス、{:ok, binary} のみ tmp+rename の atomic write)を、(cache_dir, category, TTL) でパラメータ化した上位集合 ToolKit.Cache に統合しました。

API

  • 低レベル APIput/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 APIget_or_fetch/3。生バイナリを <cache_dir>/<category>/<key> に保存し、ファイル mtime で期限判定。ttl <= 0 は常にミス(--no-cache の実装手段)。{:ok, binary} のみキャッシュ。

方針(両ツール共通)

  • キャッシュは best-effort: ディレクトリ作成・ファイル I/O の失敗で呼び出し元の処理を止めない(put/3 はエラーをタプルで報告し raise しない、get_or_fetch/3 は保存失敗を無視して取得結果を返す)。
  • 書き込みは一時ファイル + rename の atomic write

テスト

@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

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

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

全体的に設計が明確で、テストカバレッジも高く、best-effort・atomic write の方針が一貫して実装されています。以下にいくつかの改善点を指摘します。

Comment thread lib/tool_kit/cache.ex
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 では据え置きます。実運用でのファイル数増加が問題になった段階で単一読み込みへ寄せる方針とします。

Comment thread lib/tool_kit/cache.ex
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 リスクは実質同等のため、移植元とのパリティを優先して据え置きます。

Comment thread lib/tool_kit/cache.ex

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 挙動をそのまま保っています。秒精度の境界が一致する確率は実運用上ほぼ無視でき、両ツールの既存挙動と一致させることを優先して据え置きます。

Comment thread lib/tool_kit/cache.ex
`{: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 改善として検討します。

Comment thread lib/tool_kit/cache.ex
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 のエラー形を移植元と揃える利点を優先して据え置きます。

alias ToolKit.Cache.Status

# 呼び出しを self() へのメッセージで数える fetch 関数
defp counting_fetch(result) 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.

✨ [POSITIVE] [POSITIVE] counting_fetch/1 ヘルパーによるテスト設計が優れている

send(self(), :fetched) を使って fetch 呼び出し回数を assert_received/refute_received で検証するパターンは、モックライブラリ不要でキャッシュヒット/ミスの挙動を明確に検証できており、非常に読みやすいテスト設計です。

@toshi0806

Copy link
Copy Markdown
Member Author

AI レビュー対応まとめ

指摘 6 件(MEDIUM 2 / LOW 3 / POSITIVE 1)を確認しました。実バグはなく、いずれも移植元(registry-manager / thesis-monitor)の挙動保存・best-effort 方針との整合を理由に据え置き、各スレッドに根拠を返信しました(push なし)。

  • MEDIUM 二重読み込み(status/stats): 移植元の stat 基準統計を保つため据え置き。レビュアーも「best-effort では許容範囲」と評価。
  • MEDIUM read_fresh の TOCTOU: 移植元 thesis-monitor の stat→read 順とパリティ。read→stat でも窓を移すだけで best-effort 上リスク同等。
  • LOW expired? の :eq 境界: 移植元 compare == :gt 挙動を保存。
  • LOW put の負 TTL: 即時期限切れエントリの明示生成という意図的仕様。get_or_fetch の ttl<=0(--no-cache)とは別意味論。
  • LOW atomic_write のエラータグ: 移植元と同じ単一タグ。best-effort で本体に影響せず。
  • POSITIVE counting_fetch テスト設計: 指摘のとおり。

品質ゲートは全 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
親 epic: smkwlab/latex-ecosystem#146

@toshi0806
toshi0806 merged commit 9923200 into main Jul 23, 2026
5 checks passed
@toshi0806
toshi0806 deleted the issue-6-file-cache branch July 23, 2026 04:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add file cache with TTL and get_or_fetch (ToolKit.Cache)

1 participant