ActiveStorage::Blob の再アップロードを防ぐ修正

crmne/ruby_llm

with: パラメータに ActiveStorage::Blob を渡した際に発生していた意図しない再アップロードのバグが修正されました。convert_to_active_storage_format が ActiveStorage オブジェクトを検出して直接返すことで、既存の Blob を再利用できるようになります。

背景

ActiveStorage::Blobwith: パラメータ経由で渡した場合、既存の Blob が再利用されずに新たなアップロードが発生するという問題がありました(#665)。特に Google Cloud Storage など外部ストレージで、ファイルキーを "my/file/path.ext" のように明示的に指定して管理しているケースでは、元の Blob が「孤立」し、ストレージ上に重複ファイルが発生していました。

原因は build_content メソッドのラッピング処理にありました。chat.askcreate_user_messageActiveStorage::Blob を渡すと、build_content が内部で RubyLLM::Content.new を呼び出し、Blob が RubyLLM::Attachment にラップされます。この時点で元のオブジェクトの型情報が失われるため、後続の prepare_for_active_storageActiveStorage::Blob であることを認識できず、convert_to_active_storage_format が無条件に IO ハッシュを生成してしまっていました。

その結果、ActiveRecord のアタッチメント処理は IO ハッシュを受け取るたびに新しい Blob を生成・アップロードするという動作を繰り返していました。

技術的な変更

convert_to_active_storage_format に ActiveStorage オブジェクトの検出ロジックが追加され、対象オブジェクトの種類に応じて処理を分岐するようになりました。

変更前:

attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)

{
  io: StringIO.new(attachment.content),
  filename: attachment.filename,
  content_type: attachment.mime_type
}

変更後:

attachment = source.is_a?(RubyLLM::Attachment) ? source : RubyLLM::Attachment.new(source)

if attachment.active_storage?
  case attachment.source
  when ActiveStorage::Blob then attachment.source
  when ActiveStorage::Attached::One, ActiveStorage::Attached::Many then attachment.source.blobs
  end
else
  {
    io: StringIO.new(attachment.content),
    filename: attachment.filename,
    content_type: attachment.mime_type
  }
end

attachment.active_storage? で ActiveStorage 由来かどうかを判定し、ActiveStorage::Blob の場合はそのまま返却、ActiveStorage::Attached::One および ActiveStorage::Attached::Many の場合は .blobs で Blob のコレクションを返します。非 ActiveStorage オブジェクトは従来どおり IO ハッシュに変換されるため、既存の動作に影響はありません。

テストも合わせて追加されています。ActiveStorage::Blob.count が変化しないことを expect { ... }.not_to change(ActiveStorage::Blob, :count) で検証し、かつアタッチされた Blob の ID が元の Blob と一致することも確認しています。

it 'reuses an existing ActiveStorage::Blob without re-uploading' do
  chat = Chat.create!(model: model)

  existing_blob = ActiveStorage::Blob.create_and_upload!(
    io: attachment_io(image_path),
    filename: 'ruby.png',
    content_type: 'image/png'
  )

  expect do
    chat.create_user_message('What do you see?', with: existing_blob)
  end.not_to change(ActiveStorage::Blob, :count)

  user_message = chat.messages.find_by(role: 'user')
  expect(user_message.attachments.count).to eq(1)
  expect(user_message.attachments.first.blob_id).to eq(existing_blob.id)
end

設計判断

prepare_for_active_storage の入口ではなく convert_to_active_storage_format の出口で検出するアプローチが採用されました。

Issue #665 のコメントでは、prepare_for_active_storage 側で有効な ActiveStorage アタッチャブルハッシュを別扱いする案も提案されていました。しかしこの修正では、RubyLLM::Attachment へのラッピングをそのまま維持しつつ、変換処理の最終段階で ActiveStorage オブジェクトを素通しさせる方針を選択しています。これにより build_content 以降の処理フローを変えずに済み、変更範囲を最小限に抑えられています。

ActiveStorage::Attached::OneActiveStorage::Attached::Many の両型も case 文でカバーしていることで、モデルの has_one_attached / has_many_attached どちらの関連からも直接渡せるようになっており、ユーザーが使いやすい形に配慮した実装といえます。

まとめ

この修正は、ラッピング処理によって失われていた型情報を変換メソッド内で復元するという、ピンポイントな対処です。IO ハッシュへの変換を「ActiveStorage オブジェクトでない場合のみ実行する」という条件追加だけで、ストレージの二重アップロードという実運用上の問題を解消しています。

記事メタデータ

Generated by:
Claude Sonnet 4.6 for DiffDaily
LLM Trace:
03eca7cb

この記事はAIによって自動生成されています。内容の正確性については、必ずソースコードやPRを確認してください。

品質レビュー結果

Review Status:
承認済み
Review Count:
1回
Reviewed by:
Gemini 2.5 Pro for DiffDaily

Review Criteria:

記事構成 ✓ PASS

Title, Context, Technical Detailの存在と明確さ

リード文、背景、技術的な変更、設計判断、まとめという「総論→各論→結論」の構成が明確で、模範的な記事構成です。

カスタムMarkdown構文 ✓ PASS

シンタックスハイライト・GitHubリンク記法の正確性

ファイル名付きシンタックスハイライトやGitHubのIssue/PRリンク記法がガイドライン通りに正しく使用されています。

対象読者への適合性 ✓ PASS

エンジニア向けの適切な技術レベルと表現

ActiveStorageやライブラリの内部実装に関する内容であり、専門知識を持つエンジニアという対象読者に完全に適合しています。

パラグラフ・ライティング ✓ PASS

トピックセンテンス・1段落1トピック・段落長

各セクション、各パラグラフが総論から各論へと展開され、トピックセンテンスが明確です。非常に読みやすく、構成が優れています。

Diff内容との照合 ✓ PASS

コードブロックとDiff内容の一致

Diffの内容を正確に引用・反映しており、変更前後のコード比較も分かりやすく提示されています。

技術用語の正確性 ✓ PASS

技術用語の正確な使用

ActiveStorage::Blob, IO hash, RubyLLM::Attachmentなどの技術用語が文脈に沿って正確に使用されています。

説明の技術的正確性 ✓ PASS

技術的主張の正確性と論理性

バグの原因(型情報の喪失)と修正の仕組み(型に応じた処理分岐)に関する説明が、Diffの内容と一致しており、技術的に正確です。

事実の突合 ✓ PASS

PR情報による主張の裏付け(ハルシネーション検出)

記事内の主張はすべてPRのDescriptionやDiffのコードから裏付けられており、ハルシネーションは見られません。特に「設計判断」はコードから意図を汲み取った優れた分析です。

数値・固有名詞の確認 ✓ PASS

PR番号・コミットID・バージョン等の正確性

PR番号(#683)やIssue番号(#665)などの数値・固有名詞はすべて正確です。

タイトル・説明との一致 ✓ PASS

記事タイトル・説明とPR内容の一致

記事のタイトルはPRの主題を的確に要約しており、内容との一貫性が保たれています。

外部知識の正確性 ✓ PASS

PRに記載のない外部知識(LTS、サポート状況など)の不使用

記事の内容は提供されたPR情報に限定されており、裏付けのない外部知識の追加はありません。

時間表現の正確性 ✓ PASS

時間表現がPR情報と一致しているか

「発生していた」「修正されました」など、事象の時系列を正確に表現しており、時間表現の歪曲はありません。