Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: a way to handle multi-DB ActiveRecord setups #5264

Closed
geoffharcourt opened this issue Jul 6, 2020 · 13 comments
Closed

Proposal: a way to handle multi-DB ActiveRecord setups #5264

geoffharcourt opened this issue Jul 6, 2020 · 13 comments
Labels

Comments

@geoffharcourt
Copy link

geoffharcourt commented Jul 6, 2020

Rails 6 provides connection switching to allow for applications with multiple databases. One of the supported behaviors is a primary/replica setup where the replica database is read-only. Devise users will run into some issues with the default Rails 6 connection switching, as it restricts writes to non-GET/HEAD/OPTIONS requests.

As a result, some requests will trigger ActiveRecord::ReadOnlyError as a result of hooks firing, etc. Some of the Devise methods can be worked around directly in models like this:

# app/models/user.rb
def forget_me!
  ActiveRecord::Base.connected_to(role: :writing) do
    super
  end
end

Unfortunately some of the cases where writes inside a GET request don't seem quite as straightforward to work around. Some of the Warden after_sign_in hooks call #save directly on the model. Since Warden hooks are additive, it's not easy to replace the hook with a monkey-patched one. We're currently experiencing some failures for lockable that we (believe we) are not able to code around without forking Devise. Example:

Warden::Manager.after_set_user except: :fetch do |record, warden, options|
if record.respond_to?(:failed_attempts) && warden.authenticated?(options[:scope])
unless record.failed_attempts.to_i.zero?
record.failed_attempts = 0
record.save(validate: false)
end
end
end

I'd like to propose a pull request to make the Rails 6 primary/replica functionality work without requiring any changes for users who are on older versions of Rails or who aren't leveraging ActiveRecord's connected_to functionality. There are two ways I can see to approach this:

  1. Require users to wrap their model methods in a connected_to clause around super as described above. hooks/lockable would need to be updated to push its call to #save to a model method which could then be wrapped. Then we would document that this is the recommended approach for users leveraging primary/replica setups. This would be the smallest change, but would be less convenient for multiple-database users in Rails 6.

  2. Add a new configuration option, something named like devise_database_role. If that role is set, then all model saves in model modules or hooks would get wrapped in a connected_to block using that role. If the setting is not defined, then all saves happen directly as now. This approach would be more comprehensive and be a one-stop change for primary/replica setups vs. needing to patch each relevant method, but requires more invasive changes across several model modules.

I'm excited to tackle either of these changes, but looking for feedback on the preferred approach.

Related: #5133

@rafaelfranca
Copy link
Collaborator

I think the solution 2 makes sense.

But devise is ORM agnostic, so I think we should probably take care of those connection switching methods should be in the active record specific adapter.

@geoffharcourt
Copy link
Author

I suppose we could make the configuration option a Proc or lambda that would wrap all the saves, then folks could do anything they needed to for any ORM inside the block.

@trevorrjohn
Copy link

trevorrjohn commented Nov 6, 2020

I suppose we could make the configuration option a Proc or lambda that would wrap all the saves, then folks could do anything they needed to for any ORM inside the block.

@geoffharcourt have you made progress on this? Happy to help as well...

@geoffharcourt
Copy link
Author

I have a working change with a configurable proc (default is current behavior and existing tests pass), but I can't find any existing hooks tests and I'm not 100% sure how to build a test harness for those.

@trevorrjohn
Copy link

I only have bad ideas about that like inspecting Warder::Manager._after_set_user. Do you have a link to the branch?

geoffharcourt added a commit to geoffharcourt/devise that referenced this issue Nov 6, 2020
For applications using a primary/replica multiple database setup, Rails
6's default connection switching can lead to the `Lockable` after
sign-in hook firing on `GET` requests, getting the read-only database,
and then attempting to write while zeroing out the user's failed attempts.

This change adds a customizable wrapper proc that can allow applications
to customize this behavior. For a primary/replica setup using
ActiveRecord's default connection switching, it might look like this:

```ruby
Devise.setup do |config|
  config.warden_hook_save_wrapper = Proc.new {
    # Use the writable DB connection no matter what
    ApplicationRecord.connected_to(role: :writing) do
      yield
    end
  }
end
```

Fix heartcombo#5264
@geoffharcourt
Copy link
Author

geoffharcourt commented Nov 6, 2020

Hi @trevorrjohn take a peek here: #5310

geoffharcourt added a commit to geoffharcourt/devise that referenced this issue Nov 6, 2020
For applications using a primary/replica multiple database setup, Rails
6's default connection switching can lead to the `Lockable` after
sign-in hook firing on `GET` requests, getting the read-only database,
and then attempting to write while zeroing out the user's failed attempts.

This change adds a customizable wrapper proc that can allow applications
to customize this behavior. For a primary/replica setup using
ActiveRecord's default connection switching, it might look like this:

```ruby
Devise.setup do |config|
  config.warden_hook_save_wrapper = Proc.new {
    # Use the writable DB connection no matter what
    ApplicationRecord.connected_to(role: :writing) do
      yield
    end
  }
end
```

Fix heartcombo#5264
geoffharcourt added a commit to geoffharcourt/devise that referenced this issue Nov 6, 2020
For applications using a primary/replica multiple database setup, Rails
6's default connection switching can lead to the `Lockable` after
sign-in hook firing on `GET` requests, getting the read-only database,
and then attempting to write while zeroing out the user's failed attempts.

This change adds a customizable wrapper proc that can allow applications
to customize this behavior. For a primary/replica setup using
ActiveRecord's default connection switching, it might look like this:

```ruby
class ConnectionWrapper
  def process(&block)
    # Use the writable DB connection no matter what
    ApplicationRecord.connected_to(role: :writing) do
      block.call
    end
  end
end

Devise.setup do |config|
  config.warden_hook_save_wrapper = ConnectionWrapper.new
end
```

Fix heartcombo#5264
geoffharcourt added a commit to geoffharcourt/devise that referenced this issue Nov 6, 2020
For applications using a primary/replica multiple database setup, Rails
6's default connection switching can lead to the `Lockable` after
sign-in hook firing on `GET` requests, getting the read-only database,
and then attempting to write while zeroing out the user's failed attempts.

This change adds a customizable wrapper proc that can allow applications
to customize this behavior. For a primary/replica setup using
ActiveRecord's default connection switching, it might look like this:

```ruby
Devise.setup do |config|
  config.warden_hook_save_wrapper = Proc.new do |hook|
    ApplicationRecord.connected_to(role: :writing) do
      hook.call
    end
  end
end
```

Fix heartcombo#5264
freespeech4ever pushed a commit to freespeech4ever/devise that referenced this issue Feb 15, 2021
For applications using a primary/replica multiple database setup, Rails
6's default connection switching can lead to the `Lockable` after
sign-in hook firing on `GET` requests, getting the read-only database,
and then attempting to write while zeroing out the user's failed attempts.

This change adds a customizable wrapper proc that can allow applications
to customize this behavior. For a primary/replica setup using
ActiveRecord's default connection switching, it might look like this:

```ruby
Devise.setup do |config|
  config.warden_hook_save_wrapper = Proc.new do |hook|
    ApplicationRecord.connected_to(role: :writing) do
      hook.call
    end
  end
end
```

Fix heartcombo#5264
freespeech4ever pushed a commit to freespeech4ever/devise that referenced this issue Feb 15, 2021
For applications using a primary/replica multiple database setup, Rails
6's default connection switching can lead to the `Lockable` after
sign-in hook firing on `GET` requests, getting the read-only database,
and then attempting to write while zeroing out the user's failed attempts.

This change adds a customizable wrapper proc that can allow applications
to customize this behavior. For a primary/replica setup using
ActiveRecord's default connection switching, it might look like this:

```ruby
Devise.setup do |config|
  config.warden_hook_save_wrapper = Proc.new do |hook|
    ApplicationRecord.connected_to(role: :writing) do
      hook.call
    end
  end
end
```

Fix heartcombo#5264
carlosantoniodasilva added a commit that referenced this issue Mar 22, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
carlosantoniodasilva added a commit that referenced this issue Mar 22, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
carlosantoniodasilva added a commit that referenced this issue Mar 22, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
carlosantoniodasilva added a commit that referenced this issue Mar 22, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
carlosantoniodasilva added a commit that referenced this issue Apr 2, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
carlosantoniodasilva added a commit that referenced this issue Apr 2, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
carlosantoniodasilva added a commit that referenced this issue Apr 2, 2021
Resetting failed attempts after sign in happened inside a warden hook
specific for the lockable module, but that was hidden inside the hook
implementation and didn't allow any user customization.

One such customization needed for example is to direct these updates to
a write DB when using a multi-DB setup. With the logic hidden in the
warden hook this wasn't possible, now that it's exposed in a model
method much like trackable, we can override the model method to wrap it
in a connection switch block for example, point to a write DB, and
simply call `super`.

Closes #5310
Related to #5264 and #5133
@geoffharcourt
Copy link
Author

This change has been accomplished via 5d5636f. Thanks @carlosantoniodasilva!

@GuillaumeGillet
Copy link

Hi, facing the same issue, I struggle to understand how this patch have solved the multi-db issue. Is there any example or doc I can refer?

@trevorrjohn
Copy link

trevorrjohn commented Aug 12, 2021 via email

@carlosantoniodasilva
Copy link
Member

That's right, at the moment Devise doesn't have a way to automatically switch to a write connection (I have some ideas for that), so you have to override these hooks in your model and switch it yourself to the connection you want, then call super, otherwise it's just gonna use the current connection which might be a readonly one.

@GuillaumeGillet
Copy link

So based on what I read, I end up doing in my user model :

  def update_tracked_fields!(request)
    User.connected_to(role: :writing) do
      super
    end
  end

  def update_tracked_fields(request)
    User.connected_to(role: :writing) do
      super
    end
  end

  def remember_me!
    User.connected_to(role: :writing) do
      super
    end
  end

  def forget_me!
    User.connected_to(role: :writing) do
      super
    end
  end

So, so far, there is no global hook to do that. Am I right?

@trevorrjohn
Copy link

trevorrjohn commented Aug 12, 2021 via email

@GuillaumeGillet
Copy link

Ok, thx, I thought I was missing something.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Development

Successfully merging a pull request may close this issue.

5 participants