diff --git a/lib/devise.rb b/lib/devise.rb index 8e0c85e77d..10e0524989 100644 --- a/lib/devise.rb +++ b/lib/devise.rb @@ -152,6 +152,13 @@ module Test mattr_accessor :timeout_in @@timeout_in = 30.minutes + # Minimum interval between session writes when using :timeoutable. When set, + # last_request_at is only updated if more than this interval has elapsed since + # the last write, reducing database writes on busy applications. Defaults to + # nil (write on every request, preserving original behaviour). + mattr_accessor :last_request_at_update_interval + @@last_request_at_update_interval = nil + # Used to hash the password. Please generate one with rails secret. mattr_accessor :pepper @@pepper = nil diff --git a/lib/devise/hooks/timeoutable.rb b/lib/devise/hooks/timeoutable.rb index f1e7f6d577..0141c47734 100644 --- a/lib/devise/hooks/timeoutable.rb +++ b/lib/devise/hooks/timeoutable.rb @@ -29,7 +29,10 @@ end unless env['devise.skip_trackable'] - warden.session(scope)['last_request_at'] = Time.now.utc.to_i + interval = record.last_request_at_update_interval + if interval.nil? || last_request_at.nil? || (Time.now.utc - last_request_at) >= interval + warden.session(scope)['last_request_at'] = Time.now.utc.to_i + end end end end diff --git a/lib/devise/models/timeoutable.rb b/lib/devise/models/timeoutable.rb index 1d3ce2ae97..55b0ef5c25 100644 --- a/lib/devise/models/timeoutable.rb +++ b/lib/devise/models/timeoutable.rb @@ -14,6 +14,9 @@ module Models # Timeoutable adds the following options to +devise+: # # * +timeout_in+: the interval to timeout the user session without activity. + # * +last_request_at_update_interval+: the minimum time that must pass before + # last_request_at is written to the session again. Reduces database writes + # on busy apps. Defaults to nil (write on every request). # # == Examples # @@ -35,10 +38,14 @@ def timeout_in self.class.timeout_in end + def last_request_at_update_interval + self.class.last_request_at_update_interval + end + private module ClassMethods - Devise::Models.config(self, :timeout_in) + Devise::Models.config(self, :timeout_in, :last_request_at_update_interval) end end end diff --git a/lib/generators/templates/devise.rb b/lib/generators/templates/devise.rb index b36f281f25..639eb220f2 100644 --- a/lib/generators/templates/devise.rb +++ b/lib/generators/templates/devise.rb @@ -193,6 +193,14 @@ # time the user will be asked for credentials again. Default is 30 minutes. # config.timeout_in = 30.minutes + # Minimum interval between session writes for :timeoutable. When set, + # last_request_at is only updated if this much time has elapsed since the + # last write. Useful for reducing database writes on high-traffic apps (e.g. + # when using Devise::Timeoutable with a database-backed session store). + # Must be less than timeout_in to avoid premature session expiry. + # Default is nil (write on every request). + # config.last_request_at_update_interval = 1.minute + # ==> Configuration for :lockable # Defines which strategy will be used to lock an account. # :failed_attempts = Locks an account after a number of failed attempts to sign in. diff --git a/test/integration/timeoutable_test.rb b/test/integration/timeoutable_test.rb index 670ddbbe87..03c84e3521 100644 --- a/test/integration/timeoutable_test.rb +++ b/test/integration/timeoutable_test.rb @@ -208,6 +208,44 @@ def last_request_at assert warden.authenticated?(:user) end + test 'does not update last_request_at within the last_request_at_update_interval' do + swap Devise, last_request_at_update_interval: 5.minutes do + sign_in_as_user + first_request_at = last_request_at + assert_not_nil first_request_at + + get users_path + assert_equal first_request_at, last_request_at + end + end + + test 'updates last_request_at after the last_request_at_update_interval has elapsed' do + swap Devise, last_request_at_update_interval: 5.minutes do + sign_in_as_user + first_request_at = last_request_at + assert_not_nil first_request_at + + new_time = 6.minutes.from_now + Time.stubs(:now).returns(new_time) + + get users_path + assert_not_equal first_request_at, last_request_at + end + end + + test 'last_request_at_update_interval defaults to nil and writes on every request' do + assert_nil Devise.last_request_at_update_interval + + sign_in_as_user + first_request_at = last_request_at + + new_time = 10.seconds.from_now + Time.stubs(:now).returns(new_time) + + get users_path + assert_not_equal first_request_at, last_request_at + end + test 'does not crash when the last_request_at is a String' do user = sign_in_as_user