🐫

Mongoid の timeless というメソッドはとても扱いが難しいという話

に公開1

以下の挙動は、 gem mongoid のバージョン 9.0.6 での確認内容です。

https://github.com/mongodb/mongoid/tree/v9.0.6

端的に

述べると、以下の 2点です。

  • Mongoid の timeless はグローバル [1] に状態を持っており直接使うのは難しいのでやめたほうがいいです。可能な限り直接使用するのは避けたいところです。
  • Mongoid の情報を調べる中で timeless を先に発見した方がハマりがちと思われる罠ですが、ActiveRecord と同様に save(touch: false) が使えます。こっちで十分です。

Mongoid の timeless について

MongoDB を Rails で扱うためのライブラリに Mongoid があります。
操作感はかなり ActiveRecord および ActiveRecord に寄せている感じがあります。

https://github.com/mongodb/mongoid

timeless とは、この Mongoid で定義されるメソッドです。

Mongoid::Timestamps モジュール

Mongoid::Timestamps は、これを include すると created_at および updated_at がフィールドとして定義され、かつ登録時や更新時にタイムスタンプをセットしてくれます。

class Foo
  include Mongoid::Document
  include Mongoid::Timestamps

  field :bar, type: String
  field :baz, type: Integer

  validates :bar, :baz, presence: true
end
Foo.new.update!(bar: "HOGE", baz: 18)
f1 = Foo.new
f1.created_at  #=> nil
f1.updated_at  #=> nil

# 新規登録により、 created_at, updated_at がセットされる
f1.update!(bar: "HOGE", baz: 18)
f1.created_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00
f1.updated_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00

# 更新により、 updated_at がセットされる
f1.update!(baz: 20)
f1.created_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00
f1.updated_at  #=> 2025-06-01 07:35:31.438567291 UTC +00:00

Mongoid::Timestamps::Timeless

Mongoid::Timestamps::TimelessMongoid::Timestamps モジュールをインクルードすると自動的についてくるモジュールです。
このモジュールにより #timeless メソッドなどが生えます。
#timeless を宣言すると、保存時に created_at および updated_at が更新されなくなります。

# timeless すると、保存してもタイムスタンプが埋まらない

# 新規登録時
f1 = Foo.new
f1.timeless
f1.update!(bar: "HOGE", baz: 18)
f1.created_at  #=> nil
f1.updated_at  #=> nil

# 更新時
f2 = Foo.new
f2.update!(bar: "HOGE", baz: 18)
f2.created_at  #=> 2025-06-01 07:38:33.727927708 UTC +00:00
f2.updated_at  #=> 2025-06-01 07:38:33.727927708 UTC +00:00

f2.timeless
f2.update!(baz: 20)
f2.created_at  #=> 2025-06-01 07:38:33.727927708 UTC +00:00
f2.updated_at  #=> 2025-06-01 07:38:33.727927708 UTC +00:00 # 変わっていない

#timeless のふるまいについての注意点

#timeless の影響範囲

シチュエーションによっては使い所がありそうですが、 #timeless は非常に扱いが難しいメソッドです。なぜかというと、#timeless を呼び出したインスタンスだけでなくクラスそのものにも影響があるためです。

Foo.timeless?  #=> false

Foo.new.update!(bar: "HOGE", baz: 18)
f1 = Foo.last

f1.timeless
f1.timeless?  #=> true

Foo.timeless?  #=> true

そして、この影響は別のインスタンスに対しても同様に生じてしまいます。
個人的にはかなりびっくりする挙動だと思っています。

Foo.timeless?  #=> false

f1 = Foo.new
f1.update!(bar: "HOGE", baz: 18)

# f1 が実行した timeless の影響を。。。
f1.timeless
f1.timeless?  #=> true

# 別オブジェクトの f2 が受けてしまっている
f2 = Foo.new
f2.update!(bar: "FUGA", baz: 30)
f2.created_at  #=> nil
f2.updated_at  #=> nil

なお、「じゃあこの timeless 状態、いつ元に戻っているのか?」という話になるわけですが #clear_timeless_option というメソッドを呼び出すことで元の状態に戻ります。

https://github.com/mongodb/mongoid/blob/v9.0.6/lib/mongoid/timestamps/timeless.rb#L12-L25

そして、 created_atupdated_at をセットするコールバック処理の中で #clear_timeless_option は呼び出されています。

https://github.com/mongodb/mongoid/blob/v9.0.6/lib/mongoid/timestamps/created.rb#L20-L32
https://github.com/mongodb/mongoid/blob/v9.0.6/lib/mongoid/timestamps/updated.rb#L21-L32

つまり、 #save されればもとに戻るということになります。

# timeless を呼び出して直後に save する分には困ることは(あまり)ない(!?)
foo.timeless.save!

# といいますか、素直に touch: false のほうが良いです
# 厳密には save!(touch: false) の内部で timeless が使われていますが、
# 自分で timeless のステートを理解するよりも良い選択です。
foo.save!(touch: false)

具体的に困るシチュエーション例

では実際にどんなときに困るか? という話ですが例えば以下のようなアクションがあったとします。
通常の Foo の更新処理のようですが、 params[:preserve_timestamps] パラメータがあったときは更新時刻をキープするような処理です。

app/controllers/foos_controlller.rb
class FoosController < ApplicationController
  before_action :set_foo, only: %i[update]

  def create
    @foo = Foo.new(foo_params)

    if @foo.save
      # 成功時の処理
    else
      # 失敗時の処理 (エラー内容の描画など)
    end
  end

  def update
    # 特定パラメータがあるときには timeless にする
    if params.expect(:preserve_timestamps).present?
      @foo.timeless
    end

    if @foo.update(foo_params)
      # 成功時の処理
    else
      # 失敗時の処理 (エラー内容の描画など)
    end
  end

  private
    def set_foo
      @foo = Foo.find(params.expect(:id))
    end

    def foo_params = params.expect(foo: %i[bar baz])
end

このアクションに対して、 params[:preserve_timestamps] を指定した形で更新リクエストします。
ただし、 params[:foo][:bar] がブランクなのでバリデーションエラーとなるような形です。

# request update action
patch foo_path(@foo), params: { preserve_timestamps: 1, foo: { bar: "", baz: 18 } }

上記リクエストにより、バリデーションエラーなので @foo は更新されません。
しかしながら、この処理が実施されたあとには Foo.timeless? が真の状態のままです。


つづいて、今度は Foo の新規登録リクエストを実施します。今度は成功する形でリクエストします。

# request create action
post foos_path, params: { foo: { bar: "HOGE", baz: 18 } }

今度は成功するわけですが、その作成した Foo オブジェクトの created_atupdated_atnil のままになります。
なぜならば、以前のリクエスト処理により timeless 状態だったからです。

というような形で、タイムスタンプを正しくセットして欲しい場合にセットされない、という問題が再現できます。

回避策

  • 更新時に updated_at の更新を抑止したい
    • すなおに save(touch: false) を使うのが良い選択です。大抵問題ないです。
  • 登録時に created_atupdated_at を任意の値にしたい
    • 明示的に、それぞれにアサインするだけで OK です。
  • 登録時に created_at および updated_atnil のままにしたい
    • これは timeless を使う必要があるケースです。
      。。。けど、その挙動を実際に要するケースって果たして存在するのかは少々疑問です。
脚注
  1. 厳密にはスレッドローカルです ↩︎

Discussion

Takashi SakaguchiTakashi Sakaguchi

そもそもなぜクラスレベルでの状態管理をしているのか? は疑問を覚えていて、 DeepWiki と会話したのでそれの share を置きます。

https://deepwiki.com/search/mongoidtimestampstimeless-time_b3a139b2-5af2-4e89-8e65-54a74851059b

また、 save(touch: false) についても結局内部で timeless を使うのでその途中で失敗した場合の状態が気になります。
というわけで実際には以下のようなモジュールを用意して適用していたりします。

module PreserveTimestamps
  extend ActiveSupport::Concern

  included do
    include Mongoid::Document
    include Mongoid::Timestamps::Updated

    after_save :reset_preserve_timestamp
  end

  # Suppress update of updated_at when saving.
  #
  #     class Foo
  #       include Mongoid::Document
  #       include Mongoid::Timestamps
  #       include PreserveTimestamps
  #
  #       field :bar, type: String
  #       field :baz, type: Integer
  #
  #       validates :bar, :baz, presence: true
  #     end
  #
  #     f1 = Foo.new
  #     f1.created_at  #=> nil
  #     f1.updated_at  #=> nil
  #
  #     f1.update!(bar: "HOGE", baz: 18)
  #     f1.created_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00
  #     f1.updated_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00
  #
  #     f1.bar = "FUGA"
  #     f1.preserve_updated_at.save!
  #     f1.created_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00
  #     f1.updated_at  #=> 2025-06-01 07:34:45.715231132 UTC +00:00 # keep!
  #
  def preserve_updated_at
    @__preserve_updated_at = true
    self
  end

  def able_to_set_updated_at?
    super && !@__preserve_updated_at
  end

  private

  def reset_preserve_timestamp
    @__preserve_updated_at = nil
  end
end