ActiveStorage::Blob の再アップロードを防ぐ修正
with: パラメータに ActiveStorage::Blob を渡した際に発生していた意図しない再アップロードのバグが修正されました。convert_to_active_storage_format が ActiveStorage オブジェクトを検出して直接返すことで、既存の Blob を再利用できるようになります。
背景
ActiveStorage::Blob を with: パラメータ経由で渡した場合、既存の Blob が再利用されずに新たなアップロードが発生するという問題がありました(#665)。特に Google Cloud Storage など外部ストレージで、ファイルキーを "my/file/path.ext" のように明示的に指定して管理しているケースでは、元の Blob が「孤立」し、ストレージ上に重複ファイルが発生していました。
原因は build_content メソッドのラッピング処理にありました。chat.ask や create_user_message に ActiveStorage::Blob を渡すと、build_content が内部で RubyLLM::Content.new を呼び出し、Blob が RubyLLM::Attachment にラップされます。この時点で元のオブジェクトの型情報が失われるため、後続の prepare_for_active_storage は ActiveStorage::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::One と ActiveStorage::Attached::Many の両型も case 文でカバーしていることで、モデルの has_one_attached / has_many_attached どちらの関連からも直接渡せるようになっており、ユーザーが使いやすい形に配慮した実装といえます。
まとめ
この修正は、ラッピング処理によって失われていた型情報を変換メソッド内で復元するという、ピンポイントな対処です。IO ハッシュへの変換を「ActiveStorage オブジェクトでない場合のみ実行する」という条件追加だけで、ストレージの二重アップロードという実運用上の問題を解消しています。