Zeitwerk probably loads an Ability class in a different way on production?

There is a difference between how classes are loaded on production and dev and I kind figure our where does it come from.

I am using cancan. For an ability I have

module Abilities
  class AccountsAbility
    include CanCan::Ability

    def initialize user
      can :manage, User, id: user.id
    end
  end
end

In development this works

2.6.5 :001 > User.accessible_by(Abilities::AccountsAbility.new(User.first))
  User Load (0.9ms)  SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
  User Load (0.9ms)  SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL AND "users"."id" = $1 LIMIT $2  [["id", 2021270], ["LIMIT", 11]]
 => #<ActiveRecord::Relation [#<User id: 2021270, created_at: "2020-03-19 23:56:24", updated_at: "2020-06-14 12:33:50", username: "0762003">]> 

In Production the same code is

2.6.5 :022 > User.accessible_by(Abilities::AccountsAbility.new(User.first))
 => #<ActiveRecord::Relation []> 

Does anyone have an idea what might be cause this? Rails 6.0.3.1, ruby 2.6.5

Update 1.

I checked that it works correctly on a brand new project, so it is probably something as a leftover from 5.2 to 6.0 migration. A configuration somewhere.

Update 2

A different class is loaded for the two envs. Yet there is only one class in the app

Production

2.6.5 :014 > Abilities::AccountsAbility.new(User.first)
 => #<Abilities::AccountsAbility:0x000055a09f497718 @rules=[#<CanCan::Rulecan [:manage], [Abilities::User], [], {:id=>1}>], @rules_index={Abilities::User=>[0]}> 

Dev

  User Load (1.2ms)  SELECT "users".* FROM "users" WHERE "users"."deleted_at" IS NULL ORDER BY "users"."id" ASC LIMIT $1  [["LIMIT", 1]]
 => #<Abilities::AccountsAbility:0x000055e775295830 @rules=[#<CanCan::Rulecan [:manage], [User(id: integer, created_at: datetime, updated_at: datetime, username: string)=>[0]}>

Dev is loading User while production is loading Abilities::User, yet there is no such class.

Update 3

Turns out there is a Abilities::User module defined in the abilities that has and in a production env the Abilities::User is loaded when we call User.

module Abilities
  module User
    class SubscriptionsAbility
      include CanCan::Ability
      def initialize user
        ....
      end
    end
  end
end

Not sure if this is the expected behavior though. We are calling for User in the Abilities module like

module Abilities
  class AccountsAbility
    include CanCan::Ability

    def initialize user
      can :manage, User, id: user.id
    end
  end
end

and when it sees User it loads Abilities::User instead of User.

Update 3 looks like expected Ruby constant resolution behavior to me.

If you have multiple constants with the same name in different namespaces, better to be explicit with which constant you’re invoking, ie. ::User instead of User

Thanks. I guess it is proper ruby behavior. I have tried with ::User and classic loader and it was still resolving Abilities::User, which was strange. Will try again.

Also do note that constants are autoloaded in development but eager loaded in `production. That may cause some resolution discrepancies.

zeitwerk mode is consistent, the code that eager loads is not a recursive require, but a recursive autoloader. Zeitwerk mode tries hard to be consistent, if things load correctly, they do eager and lazy, if things fail, they also fail consistently.

If you have abilities/user.rb in the autoload paths, the can declaration is going to load Abilities::User always. No matter what has been loaded before (classic depended on load order in this case, zeitwerk does not).

Can you check

Rails runner 'pp ActiveSupport::Dependencies.autoload_paths'

to confirm?

1 Like

I don’t have abilities/user.rb I have abilities/user/subscriptions_ability.rb and in it I have

module Abilities
  module User
    class SubscriptionsAbility
    end
  end
end

So a different ability located at abilities/accounts_ability.rb does

can :manage, User, 

and it loads Abilities::User from abilities/user/subscriptions_ability while I was expecting that app/models/user.rb will be loaded. There is no abilities/user.rb file.

Here is the output of autoload_path

"/home/user/platform/lib",
 "/home/user/platform/app/channels",
 "/home/user/platform/app/controllers",
 "/home/user/platform/app/controllers/concerns",
 "/home/user/platform/app/datatables",
 "/home/user/platform/app/decorators",
 "/home/user/platform/app/helpers",
 "/home/user/platform/app/indices",
 "/home/user/platform/app/jobs",
 "/home/user/platform/app/mailers",
 "/home/user/platform/app/models",
 "/home/user/platform/app/models/concerns",
 "/home/user/platform/app/promote",
 "/home/user/platform/app/shrine",
 "/home/user/platform/app/themes",
 "/home/user/platform/app/channels/progressable_channel.rb",
 "/home/user/platform/app/channels/task_completions_channel.rb",
...the reset are .rvm/*

When you program in zeitwerk mode, you have to program as if all classes and modules were already in memory (as far as constant name resolution is concerned). This does not depend on load order (that is why you can think that way).

Since Abilities::User exists in your project,

can :manage, User

in a place that has Abilities in the nesting (as the examples above) will always resolve to Abilities::User, never to the top-level one. That is how constant name resolution works in Ruby.

You have two options there, make it absolute, ::User, as @kmitov suggested above, or else remove Abilities from the nesting this way:

module Abilities::User::SubscriptionsAbility
  include CanCan::Ability

  def initialize user
    can :manage, User, id: user.id
  end
end

Let me underline that this behaviour is not really related to the autoloader, it is the way Ruby works. The only thing the autoloader does is to be implemented in a way that preserves those semantics.

3 Likes

Thanks. I think I understand it.

You say

is not really related to the autoloader, it is the way Ruby works.

but depending on the autoloader and whether it is zeitwerk or classic it behaves in a different way on the same production. With zeitwerk it finds Abilities::User and with classic it finds ::User. That was the confusing part.

Thanks of the answer. Will probably declare them as " ``` module Abilities::User::SubscriptionsAbility

Ah!

Yes, the problem with classic is that it does not match Ruby semantics, and that its behaviour may depend on load order in situations like yours. This is documented in this section of the autoloading guide for classic. You can see other gotchas of classic mode in sections near that one, and that is why we’ve worked on a new autoloader.

zeitwerk mode does match Ruby semantics, and constant resolution does not depend on load order.

1 Like