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

5688: Run callbacks for children within fibers #5837

Merged

Conversation

adviti-mishra
Copy link
Contributor

Using Ruby Fibers to run callbacks for children. This helps avoid StackOverflow when callbacks cascade

@johnnyshields
Copy link
Contributor

johnnyshields commented Jul 9, 2024

@adviti-mishra cool idea!

Copy link
Contributor

@jamis jamis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is looking so great! Wonderful work on this. 🎉

@@ -0,0 +1,19 @@
# frozen_string_literal: true
# rubocop:todo all
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rubocop:todo all line is a bandaid for existing files, so that we can fix linter errors on a file-by-file basis as we work in them. For new files, let's leave that line out and just make them linter-approved from the start. Note that a lot of our existing files have linter issues, so you'll almost certainly see the linter complain about this file, at first. Fortunately, it's a small file. :)

(You can run the linter locally on a single file, with rubocop path/to/file, or on the whole project with just rubocop.)

Suggested change
# rubocop:todo all

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the context. Pushed this change because the file is linter-approved now!

Comment on lines 189 to 193
begin
fiber.resume
rescue FiberError
raise Mongoid::Errors::InvalidAroundCallback
end
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This isn't wrong, but it can be made more concise by moving the rescue to the level of the method itself.

The refactoring goes like this. Given this implementation:

def fn(list)
  list.each do |item|
    begin
      item.do_something
    rescue Exception
      raise OtherException
    end
  end
end

You can refactor it by extracting the begin/rescue/end to the level of the method:

def fn(list)
  begin
    list.each(&:do_something)
  rescue Exception
    raise OtherException
  end
end

However, every method is implicitly a begin/end block, so the begin and end are redundant. You can remove them, like so:

def fn(list)
  list.each(&:do_something)
rescue Exception
  raise OtherException
end

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is great feedback. Thank you! Pushed these changes

Copy link
Contributor

@jamis jamis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great work! 🚀

Copy link
Contributor

@comandeo-mongo comandeo-mongo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Great job @adviti-mishra, such an elegant solution! 👍

fibers.reverse.each(&:resume)

rescue FiberError
raise Mongoid::Errors::InvalidAroundCallback
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we sure that any FiberError is caused by non providing yield in a callback? If no, can we somehow detect what happens or adjust the error description?

Copy link
Contributor

@alexbevi alexbevi Jul 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From the API docs a FiberError is "Raised when an invalid operation is attempted on a Fiber, in particular when attempting to call/resume a dead fiber, attempting to yield from the root fiber, or calling a fiber across threads."

Not sure if differentiating these failure conditions would help any, but error specificity is never a bad thing.

Copy link
Contributor Author

@adviti-mishra adviti-mishra Jul 18, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since our program and not the user handles what fibers are created and resumed, the only condition under which a dead fiber seems to be resumed is if the user does not call yield. This falls under the "attempting to call/resume a dead fiber" bucket. I believe specificity here may be helpful because users don't even need to know fibers are used underneath. However, I'm fine with leaving the error message to be more general as well.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would think suppressing these fiber errors (possibly with a log line only) would be the right way to go. Probably these will mostly pop-up when terminating an app.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As I think about this, it occurs to me that there could be situations where a user might do something with fibers in their own code, in a callback (weird, but feasible). FiberError exceptions from their code could then be potentially captured here, resulting in a confusing error message.

I wonder if it might be safer to explicitly look to see if the fiber is still #alive? after the first resume, and if it isn't, raise the InvalidAroundCallback exception there?

f = Fiber.new { nil }
f.alive? #-> true
f.resume
f.alive? #-> false

Copy link
Contributor

@johnnyshields johnnyshields Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamis I think raising InvalidAroundCallback here if the fiber dies prematurely may not be a good idea. It's probably best to let the Fiber die gracefully with a log line. The scenario you are describing is analogous to a non-Fiber around callback not calling yield but instead exiting early. This is not encouraged but it is valid behavior currently, I believe.

TLDR; I do not think we should make Fibers do anything that current callbacks don't do.

Copy link
Contributor

@jamis jamis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good. Just one suggestion for the specs. 👍

Comment on lines 2620 to 2621
foo = Foo.create
2.times { foo.bars.build }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you declare a value at the context level like this, it will be evaluated once for the entire context. In this case, it doesn't matter (because there's only one test), but in general you want the value to be "clean" for each test. To accomplish this, rspec provides a let method to declare these variables in a way that allows you to re-evaluate them for each test, e.g.:

Suggested change
foo = Foo.create
2.times { foo.bars.build }
let(:foo) do
Foo.create.tap do |foo|
2.times { foo.bars.build }
end
end

We can make it a bit more concise by providing the bars list to the #create call, like this:

Suggested change
foo = Foo.create
2.times { foo.bars.build }
let(:foo) { Foo.create(bars: [ Bar.new, Bar.new ]) }

module Errors
# This error is raised when an around callback is
# defined by the user without a yield
class InvalidAroundCallback < MongoidError
Copy link
Contributor

@johnnyshields johnnyshields Jul 19, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As per comment elsewhere, I am strongly opposed to introducing a new runtime error during persistence flow which users will need to handle in their code. Callbacks today do not raise such errors.

Now I will have to do:

begin
  dog.save!
rescue InvalidAroundCallback
  # ...
end

everywhere in my app.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johnnyshields -- this exception is raised only for a single case: when an around callback fails to yield. This will always indicate a bug in the users code. If we did not raise the exception here, it would manifest instead as a FiberError, which would be confusing to users because it does not describe what the issue is.

Rather than risk confusing users with a seeming non-sequitur, we're opting to raise this new error to help direct them to where the bug is in their code. If you always yield from your around callbacks, you'll never encounter the error.

Copy link
Contributor

@johnnyshields johnnyshields Jul 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@jamis I understand that, but today (prior to the Fiber version) you can do an around callback which doesn't yield and it doesn't raise any error as far as I'm aware--it just doesn't continue the callback chain.

Is there a possibility that users are using non-yielding callbacks for flow control of their actions?

Also, what if we throw :abort in a callback?

I think this should be looked at in more detail...

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your concern about this issue, @johnnyshields, but we really have investigated this and and have looked at this, in quite a bit of detail.

An around callback that fails to yield is not as innocuous as you think. Not only does it just fail to "continue the callback chain" -- it fails to perform the intended operation at all. That is to say, if you have an around_save without a yield, and you save the record, it will silently fail to save the record at all.

This is why I said that this is a bug in their code, and I don't feel bad about surfacing this noisily now, where we didn't before. If someone's code "breaks" because of this, it will break in a way that it should have broken before. I do not believe there is any way that anyone was using non-yielding around callbacks for any useful purpose, because they wouldn't have been functional at all.

Copy link
Contributor

@johnnyshields johnnyshields Jul 20, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, what if someone is intentionally using that behavior like a validation in order to abort the save flow? For example:

around_save :do_not_save_if_red

def do_not_save_if_red
  if color == "red"
    # report error somehow
  else
    yield
  end
end

App owners will now need to check that no callback is implementing the above pattern. Callbacks in Mongoid models now work differently than controllers, for example, which would allow the above.

@adviti-mishra adviti-mishra marked this pull request as ready for review July 19, 2024 16:54
@adviti-mishra adviti-mishra requested a review from jamis July 19, 2024 16:54
spec/mongoid/interceptable_spec.rb Outdated Show resolved Hide resolved
@adviti-mishra adviti-mishra requested a review from jamis July 19, 2024 18:15
Copy link
Contributor

@jamis jamis left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me! 👍 🎉

@adviti-mishra adviti-mishra merged commit c5e092a into mongodb:master Jul 19, 2024
58 checks passed
@adviti-mishra adviti-mishra deleted the 5688-fibers-run-child-callbacks branch July 19, 2024 18:18
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

5 participants