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

Play nice with sorbet and tapioca #2189

Open
Velrok opened this issue Dec 5, 2023 · 8 comments
Open

Play nice with sorbet and tapioca #2189

Velrok opened this issue Dec 5, 2023 · 8 comments
Assignees

Comments

@Velrok
Copy link

Velrok commented Dec 5, 2023

Describe the idea

It would be nice if the monitor_check_ins.rb wouldn't use preprend, because that means we can't adopt it alongside sorbet atm.

Why do you think it's beneficial to most of the users

Sorbet is a mature type checker for ruby, which is great to have in large code bases. They decided not to support prepend, which seams reasonable to me.

Possible implementation

Sorry I don't understand prepend well enough to really comment. Couldn't it be wrapped in some sort of block / yield alternative?

@sl0thentr0py
Copy link
Member

@Velrok some of our other patches (redis/net-http) also use prepend, how does that work then?

@Velrok
Copy link
Author

Velrok commented Dec 6, 2023

@Velrok some of our other patches (redis/net-http) also use prepend, how does that work then?

Hmm not sure.
This blows up when we try to run

bin/tapioca dsl

Which will start the rails app and inspect the classes at runtime.

Maybe the other patches are not loaded?

Is sorbet compatible something you are interested in supporting?
If so I could probably negotiate some time to produce a minimal failing example.

@sl0thentr0py
Copy link
Member

sl0thentr0py commented Dec 7, 2023

@Velrok I haven't really thought about it since you're the first person who asked.
I can give it some thought if you give me a simple repro, yes.
In general, we at sentry like to be as close to 'zero config' as possible so we do have to do some monkeypatching for that but if there's an easy way around I can see.

@jgrau
Copy link

jgrau commented Jan 19, 2024

This is not tested but I found myself in the same situation and thought about adding something like this to ApplicationJob:

  sig { params(slug: String, monitor_config: Sentry::Cron::MonitorConfig).void }
  def self.sentry_monitor_check_ins(slug:, monitor_config:)
    around_perform do |_job, block|
      check_in_id = Sentry.capture_check_in(slug, :in_progress, monitor_config:)

      start = Sentry.utc_now.to_i

      begin
        block.call
        duration = Sentry.utc_now.to_i - start

        Sentry.capture_check_in(
          slug,
          :ok,
          check_in_id:,
          duration:,
          monitor_config:,
        )
      rescue StandardError
        duration = Sentry.utc_now.to_i - start

        Sentry.capture_check_in(
          slug,
          :error,
          check_in_id:,
          duration:,
          monitor_config:,
        )

        raise
      end
    end
  end

It

  • configures an around_perform hook on the job using the same interface as the sentry mixin.
  • works like the sentry mixin: checks in, runs job, checks out.

By having the same interface you should be able to replace that method with the sentry mixin if/when sentry starts playing nice with Sorbet.

In the end I made another solution so I never tested this in production. I did run some tests in development and that seemed to work just fine.

@st0012
Copy link
Collaborator

st0012 commented Feb 25, 2024

I want to first clarify that while Sorbet doesn't support prepend, it simply means it can't properly infer the method when it's defined through prepend. It does NOT mean projects can't have any prepend if they use Sorbet. Even Tapioca itself uses prepend in a few places (example).

Secondly, if something crashes when running tapioca dsl, it has nothing to do with Sorbet. The command, as you said, loads the application and uses the runtime info to generate rbi files. If things go wrong here, it's purely a runtime issue, not related to Sorbet.

And since it's a runtime issue, it's likely caused by things not being loaded properly. The solution could be:

  • You need to make sure certain things is required during the process by adding them into sorbet/tapioca/require.rb
  • Or you need to modify sorbet/tapioca/config.yml to exclude certain gems

However, without further information we can't properly suggest a better solution here. Can you please add more information, like:

  • Sorbet version and Tapioca version
  • Error stacktrace
  • Tapioca configs

@jgrau
Copy link

jgrau commented Mar 13, 2024

@st0012 thanks for the feedback. I do believe the crash Sorbet+Tapioca users experience here has to do with prepend and is not a loading issue. Here's the information you requested:

Versions:

    sorbet (0.5.11287)
      sorbet-static (= 0.5.11287)
    sorbet-runtime (0.5.11287)
    sorbet-static (0.5.11287-universal-darwin)
    sorbet-static (0.5.11287-x86_64-linux)
    sorbet-static-and-runtime (0.5.11287)
      sorbet (= 0.5.11287)
      sorbet-runtime (= 0.5.11287)
    sorbet-struct-comparable (1.3.0)
      sorbet-runtime (>= 0.5)
    tapioca (0.12.0)

Error backtrace:

❯ task tapioca:dsl
task: [tapioca:dsl] bin/tapioca dsl
Loading DSL extension classes... Done
Loading Rails application... 
Tapioca attempted to load the Rails application after encountering a `config/application.rb` file, but it failed. If your application uses Rails please ensure it can be loaded correctly before generating RBIs. If your application does not use Rails and you wish to continue RBI generation please pass `--no-halt-upon-load-error` to the tapioca command in sorbet/tapioca/config.yml or in CLI.
You're trying to replace `perform` on `CacheListingMarketCommand::CronJob`, but that method exists in a prepended module (Sentry::Cron::MonitorCheckIns::Patch), which we don't currently support.
/Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/class_utils.rb:115:in `block in replace_method': You're trying to replace `perform` on `CacheListingMarketCommand::CronJob`, but that method exists in a prepended module (Sentry::Cron::MonitorCheckIns::Patch), which we don't currently support. (RuntimeError)
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/class_utils.rb:105:in `each'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/class_utils.rb:105:in `replace_method'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:257:in `_on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:549:in `method_added'
        from /Users/jgrau/src/landfolk/apps/api/app/commands/cache_listing_market_command.rb:75:in `<class:CronJob>'
        from /Users/jgrau/src/landfolk/apps/api/app/commands/cache_listing_market_command.rb:66:in `<class:CacheListingMarketCommand>'
        from /Users/jgrau/src/landfolk/apps/api/app/commands/cache_listing_market_command.rb:7:in `<main>'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/3.3.0/bundled_gems.rb:74:in `require'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/3.3.0/bundled_gems.rb:74:in `block (2 levels) in replace_require'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/bootsnap-1.18.3/lib/bootsnap/load_path_cache/core_ext/kernel_require.rb:30:in `require'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/kernel.rb:26:in `require'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/helpers.rb:135:in `const_get'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/helpers.rb:135:in `cget'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:175:in `block in actual_eager_load_dir'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/helpers.rb:40:in `block in ls'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/helpers.rb:25:in `each'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/helpers.rb:25:in `ls'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:170:in `actual_eager_load_dir'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:17:in `block (2 levels) in eager_load'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:16:in `each'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:16:in `block in eager_load'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:10:in `synchronize'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader/eager_load.rb:10:in `eager_load'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader.rb:379:in `block in eager_load_all'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader.rb:377:in `each'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/zeitwerk-2.6.13/lib/zeitwerk/loader.rb:377:in `eager_load_all'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/loaders/loader.rb:206:in `eager_load_rails_app'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/loaders/loader.rb:60:in `load_rails_application'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/loaders/dsl.rb:84:in `load_application'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/loaders/dsl.rb:29:in `load'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/loaders/dsl.rb:22:in `load_application'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/commands/abstract_dsl.rb:108:in `load_application'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/commands/dsl_generate.rb:11:in `execute'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/commands/command.rb:27:in `block in run'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca.rb:23:in `block in silence_warnings'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/3.3.0/rubygems/user_interaction.rb:46:in `use_ui'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca.rb:22:in `silence_warnings'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/commands/command.rb:26:in `run'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `bind_call'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/sorbet-runtime-0.5.11287/lib/types/private/methods/_methods.rb:279:in `block in _on_method_added'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/lib/tapioca/cli.rb:168:in `dsl'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/thor-1.3.1/lib/thor/command.rb:28:in `run'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/thor-1.3.1/lib/thor/invocation.rb:127:in `invoke_command'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/thor-1.3.1/lib/thor.rb:527:in `dispatch'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/thor-1.3.1/lib/thor/base.rb:584:in `start'
        from /Users/jgrau/.asdf/installs/ruby/3.3.0/lib/ruby/gems/3.3.0/gems/tapioca-0.12.0/exe/tapioca:25:in `<top (required)>'
        from /Users/jgrau/src/landfolk/apps/api/bin/tapioca:27:in `load'
        from /Users/jgrau/src/landfolk/apps/api/bin/tapioca:27:in `<main>'
task: Failed to run task "tapioca:dsl": exit status 1

Tapioca config:

gem:
  # Add your `gem` command parameters here:
  #
  exclude:
    []
    # - red-arrow
    # - red-parquet
  # doc: true
  # workers: 5
dsl:
  # Add your `dsl` command parameters here:
  #
  # exclude:
  # - SomeGeneratorName
  # workers: 5

The sorbet-runtime method that raises the error looks like this:

  # Replaces a method, either by overwriting it (if it is defined directly on `mod`) or by
  # overriding it (if it is defined by one of mod's ancestors).  If `original_only` is
  # false, returns a ReplacedMethod instance on which you can call `bind(...).call(...)`
  # to call the original method, or `restore` to restore the original method (by
  # overwriting or removing the override).
  #
  # If `original_only` is true, return the `UnboundMethod` representing the original method.
  def self.replace_method(mod, name, original_only=false, &blk)
    original_method = mod.instance_method(name)
    original_visibility = visibility_method_name(mod, name)
    original_owner = original_method.owner

    mod.ancestors.each do |ancestor|
      break if ancestor == mod
      if ancestor == original_owner
        # If we get here, that means the method we're trying to replace exists on a *prepended*
        # mixin, which means in order to supersede it, we'd need to create a method on a new
        # module that we'd prepend before `ancestor`. The problem with that approach is there'd
        # be no way to remove that new module after prepending it, so we'd be left with these
        # empty anonymous modules in the ancestor chain after calling `restore`.
        #
        # That's not necessarily a deal breaker, but for now, we're keeping it as unsupported.
        raise "You're trying to replace `#{name}` on `#{mod}`, but that method exists in a " \
              "prepended module (#{ancestor}), which we don't currently support."
      end
    end

    overwritten = original_owner == mod
    T::Configuration.without_ruby_warnings do
      T::Private::DeclState.current.without_on_method_added do
        def_with_visibility(mod, name, original_visibility, &blk)
      end
    end

    if original_only
      original_method
    else
      new_method = mod.instance_method(name)
      ReplacedMethod.new(mod, original_method, new_method, overwritten, original_visibility)
    end
  end

which leads me to think that the error is intentional and not a bug or a loading issue.

@st0012
Copy link
Collaborator

st0012 commented Mar 29, 2024

Thank you for the information, I now understand the problem (also described in great detail in this issue). Unfortunately, I don't think not using prepend is possible because the alternative would be something like alias_method_chain, which when other gems use prepend, would cause infinite loop instead (which is also why it's a deprecated practice).

I think for now the only way to avoid this is to opt-out the patch that triggers the prepend. Given the class's name has CronJob, I assume you probably use sidekiq-cron? If you do, can you try adding this to your Sentry config file:

Sentry.init { ... } 
Sentry.registered_patches.delete(:sidekiq_cron)

@sethherr
Copy link

sethherr commented Apr 5, 2024

A related/separate disadvantage of prepend is that you can't inherit sentry_monitor_check_ins from parent classes.

For example:

class BaseScheduledJob
  sentry_monitor_check_ins

  def perform
    # this calls Sentry::Cron::MonitorCheckIns::Patch#perform
  end
end

class SomeSpecificScheduledJob < BaseScheduledJob
  def perform
    # this doesn't call Sentry::Cron::MonitorCheckIns::Patch#perform
  end
end

This is subtle and took a while for me to figure out (I learned some new things!). It makes me feel like prepend might not be the right solution here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
Status: No status
Status: No status
Development

No branches or pull requests

5 participants