`Rails.cache.read` に `delete: true` オプションを追加——Redisのアトミックな読み取り削除に対応
Rails.cache.read に delete: true オプションが追加され、RedisCacheStore においてRedisの GETDEL コマンドを使ったアトミックな読み取りと削除が可能になりました。OTPコードやワンタイムトークンなど、一度しか消費できない値の管理で発生しがちな競合状態を根本から排除できます。
背景
これまで「読み取ってから削除する」パターンを実装するには、read と delete を別々に呼び出す必要がありました。この2ステップ操作には本質的な競合状態が存在し、複数のプロセスが同じキーを同時に読み取った場合、いずれも値を取得できてしまいます。OTPコードや使い切りトークンのような「一度しか消費できない」ユースケースでは、この競合が致命的なセキュリティ上の問題になり得ます。
Redis 6.2で導入された GETDEL コマンドは、値の取得とキーの削除をサーバー側で単一のラウンドトリップにまとめ、この問題をアトミックに解決します。本PRはそのコマンドをRailsのキャッシュAPIから直接利用できるようにしています。
技術的な変更
変更は RedisCacheStore、LocalCache の2層のキャッシュ実装と、テストの3箇所にわたっています。
RedisCacheStore#read_serialized_entry の拡張
RedisCacheStore の内部メソッドである read_serialized_entry が delete キーワード引数を受け取るよう拡張されました。options[:delete] が真のとき、c.get(key) の代わりに c.getdel(key) を呼び出します。
変更前:
def read_serialized_entry(key, raw: false, **options)
failsafe :read_entry do
redis.then { |c| c.get(key) }
end
end
変更後:
def read_serialized_entry(key, raw: false, **options)
failsafe :read_entry do
redis.then { |c| options[:delete] ? c.getdel(key) : c.get(key) }
end
end
変更は1行の三項演算子のみであり、既存の read 呼び出しへの影響はありません。
LocalCache でのバイパスと整合性維持
Strategy::LocalCache#read_serialized_entry では、delete: true のとき専用の分岐が設けられています。ローカルキャッシュが有効な場合でも super(Redisへのアクセス)を直接呼び出し、その後 cache.delete_entry(key) でインメモリキャッシュの該当エントリを削除します。
変更前:
def read_serialized_entry(key, raw: false, **options)
if cache = local_cache
hit = true
entry = cache.fetch_entry(key) do
hit = false
super
end
options[:event][:store] = cache.class.name if hit && options[:event]
entry
else
super
end
end
変更後:
def read_serialized_entry(key, raw: false, **options)
if cache = local_cache
if options[:delete]
entry = super
cache.delete_entry(key)
entry
else
hit = true
entry = cache.fetch_entry(key) do
hit = false
super
end
options[:event][:store] = cache.class.name if hit && options[:event]
entry
end
else
super
end
end
この実装には2つの重要な意図があります。まず、ローカルキャッシュに古い値が残っていても GETDEL は必ずRedisまで到達する必要があるため、fetch_entry(ローカルキャッシュを先に参照するパス)をバイパスしています。次に、Redisの削除操作が完了した後にローカルキャッシュも削除することで、両レイヤー間の整合性を維持しています。
他のキャッシュストアへの影響
delete: true オプションは **options のスプラット経由で他のキャッシュストアに渡されますが、それらのストアはこのオプションを認識しないため、静かに無視されます。これは既存のキャッシュストアとの後方互換性を保ちながら機能を追加する、Railsらしいアプローチです。
設計判断
Redisレイヤーでのアトミック操作をRailsのキャッシュ抽象化に薄く包んだ設計が採用されました。
新しい専用メソッド(例: read_and_delete)を追加するのではなく、既存の read にオプションを追加することで、APIの増加を最小限に抑えています。PR説明にもあるとおり、このオプションはRedis固有であり、他のキャッシュストアでは暗黙的に無視される動作が明確に文書化されています。これは汎用的な抽象化よりも、特定のバックエンドの能力を直接活用することを優先した判断といえます。
なお、PR内では GETDEL が導入されたRedis 6.2未満のバージョンへの対応について懸念も示されています。現時点ではバージョンチェックは実装されておらず、古いバージョンのRedisを使用している場合はエラーが発生する可能性があります。
まとめ
本PRは、キャッシュAPIの既存インターフェースを最小限の変更で拡張し、Redisの GETDEL コマンドが持つアトミック性を直接活用できるようにしました。読み取りと削除の間に競合状態が生じ得るワンタイムトークン管理のような実装パターンを、Rails.cache.read(key, delete: true) という一行で置き換えられる点は、実用上の価値が高い変更です。