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

Fix define_derived_metadata so that it supports cascades. #2630

Merged
merged 1 commit into from May 29, 2019

Conversation

myronmarston
Copy link
Member

For example:

RSpec.configure do |c|
  c.define_derived_metadata(:elasticsearch) do |meta|
    meta[:vcr] = true
  end

  c.define_derived_metadata(:vcr) do |meta|
    meta[:retries] = 2
  end
end

With this configuration, an example or group tagged with :elasticsearch
should get tagged with :vcr as well, which in turn should add
retries: 2 metadata to the example or group. Before this change,
this did not work properly, because we did look to see if additional
metadata blocks should apply after applying them once.

expect(group.metadata).to include(foo1: 1, foo2: 1, foo3: 1, foo4: 1)

ex = RSpec.describe("My group").example("foo", foo1: 1)
expect(ex.metadata).to include(foo1: 1, foo2: 1, foo3: 1, foo4: 1)
Copy link
Member

Choose a reason for hiding this comment

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

These are being treated as hashes, of course, but we still support 1.8.7, so we need the hash rocket syntax :)

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks. Fixed.

@@ -1819,6 +1819,20 @@ def exclude?(line)
expect(group.metadata).to include(:b1_desc => "bar (block 1)", :b2_desc => "bar (block 1) (block 2)")
end

it 'supports cascades of derived metadata, but avoids re-running derived metadata blocks that have already been applied' do
Copy link
Member

Choose a reason for hiding this comment

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

I see how this demonstrates the cascade, but not the re-applied logic...

Copy link
Member Author

Choose a reason for hiding this comment

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

It demonstrates that the blocks are not re-applied by asserting that the values of each of foo* is 1. The blocks each increment the value, so if any of the define_derived_metadata blocks are run multiple times, then the valule would be something greater than 1 and the expectation below would fail.

How can I make this more clear?

Copy link
Member

Choose a reason for hiding this comment

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

I wouldn't expect in this scenario the blocks to be run more than once though, but this would demonstrate the issue more clearly:

c.define_derived_metadata(:foo1) { |m| m[:foo2] = (m[:foo2] || 0) + 1 }
c.define_derived_metadata(:foo2) { |m| m[:foo3] = (m[:foo3] || 0) + 1 }
c.define_derived_metadata(:foo3) { |m| m[:foo1] = (m[:foo1] || 0) + 1 }

group = RSpec.describe("bar", :foo1 => 1)
expect(group.metadata).to include(:foo1 => 2, :foo2 => 1, :foo3 => 1)

Copy link
Member Author

Choose a reason for hiding this comment

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

I wouldn't expect in this scenario the blocks to be run more than once though

They would run more than once if it wasn't for the unless skip block bit on the block.call(metadata) unless skip_block line.

Anyhow, I've changed to:

        RSpec.configure do |c|
          c.define_derived_metadata(:foo1) { |m| m[:foo2] = (m[:foo2] || 0) + 1 }
          c.define_derived_metadata(:foo2) { |m| m[:foo3] = (m[:foo3] || 0) + 1 }
          c.define_derived_metadata(:foo3) { |m| m[:foo1] += 1 }
        end

        group = RSpec.describe("bar", :foo1 => 0)
        expect(group.metadata).to include(:foo1 => 1, :foo2 => 1, :foo3 => 1)

I think it's a bit easier to see that each block runs only once if the values are all 1 (if one value is 2 it makes it seem like a block ran twice I think), so we can start foo1 at 0 to achieve this.

# We loop and attempt to re-apply metadata blocks to support cascades
# (e.g. where a derived bit of metadata triggers the application of
# another piece of derived metadata, etc)
loop do
Copy link
Member

Choose a reason for hiding this comment

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

Isn't there potential here for an infinite loop? Can we add a "break" that limits it to something even if it's rather high?

Copy link
Member Author

Choose a reason for hiding this comment

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

As long as the user is not defining new derived_metadata_blocks within a derived metadata block, I think it is guaranteed to terminate. Here's my reasoning:

  • Assume that @derived_metadata_blocks is not changing (I'll discuss that case separately).
  • On a given loop iteration, one of two things will happen:
    • @derived_metadata_blocks.items_for(metadata) will return 1 or more blocks that have not previously been returned on a prior loop iteration
    • @derived_metadata_blocks.items_for(metadata) will return the same set of block that have been previously returned
  • If it's the latter case, the all? will return true (because already_run_blocks.include?(block) will be true for all blocks), and the loop will break.
  • If it's the former case, the newly returned blocks will be called and added to already_run_blocks, and the loop will iterate again. However, we will be closer to having run all of the blocks in @derived_metadata_blocks, and there is no way for the former case to happen indefinitely. At some point the latter case much occur because @derived_metadata_blocks has a finite number of blocks.

The assumption I stated above could be violated with some weird code like:

RSpec.configure do |c|
  c.define_derived_metadata(a: 0) do |meta|
    met[:a] += 1
    c.define_derived_metadata(a: meta[:a] + 1) do
      # ...
    end
  end
end

e.g. where the user is adding new blocks each time the block is triggered, and updating the metadata to match it. I don't think this is a case any user will actually try, though, and if they do, well it's no different than a user writing a let that is self-referential and therefore recurses indefinitely. Infinite recursion is possible with many constructs in Ruby and RSpec, and I don't think there's a viable way for us to guard against that without also preventing potentially valid use cases.

We could add a hard limit, but any limit would be arbitrary, and for any limit we can come up with, a valid use case exists for it happening once more. Plus, it would be very odd to abort at an arbitrary point; it would be like a ruby method that recurses on itself arbitrarily stopping after some number of iterations.

Copy link
Member

Choose a reason for hiding this comment

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

it would be like a ruby method that recurses on itself arbitrarily stopping after some number of iterations.

Which is exactly what a StackLevelTooDeep error is... and this would hang indefinitely instead

Copy link
Member Author

Choose a reason for hiding this comment

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

Good point; raising SystemStackError is reasonable. I thought you were suggesting that we silently stop after a certain number of iterations.

I've updated this to raise SystemStackError after 200 iterations.

Copy link
Member

Choose a reason for hiding this comment

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

Apologies for the confusion, I was advocating failing noisily if a limit was hit, I agree a stack error seems appropriate. I ponder if this could be refactored to be a recursive method to trigger the error organically at the same limit as the Ruby vm? Not a blocker however.

Copy link
Member Author

Choose a reason for hiding this comment

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

I wondered the same thing, but can't think of a way to refactor it that way.

Copy link
Member Author

Choose a reason for hiding this comment

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

Can you stamp this, @JonRowe?

@myronmarston myronmarston force-pushed the myron/fix-define-derived-metadata-cascades branch 2 times, most recently from d753818 to 6b5fbcc Compare May 28, 2019 16:08
tag = :"foo#{counter += 1}"
meta[tag] = true

c.define_derived_metadata(tag) do |meta|
Copy link
Member

Choose a reason for hiding this comment

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

Rubocop is complaining about shadowing here.

Copy link
Member Author

Choose a reason for hiding this comment

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

Thanks, fixed.

For example:

```
RSpec.configure do |c|
  c.define_derived_metadata(:elasticsearch) do |meta|
    meta[:vcr] = true
  end

  c.define_derived_metadata(:vcr) do |meta|
    meta[:retries] = 2
  end
end
```

With this configuration, an example or group tagged with `:elasticsearch`
should get tagged with `:vcr` as well, which in turn should add
`retries: 2` metadata to the example or group. Before this change,
this did not work properly, because we did look to see if additional
metadata blocks should apply after applying them once.
@myronmarston myronmarston force-pushed the myron/fix-define-derived-metadata-cascades branch from 6b5fbcc to 71567cd Compare May 29, 2019 00:16
@JonRowe JonRowe merged commit e0ecbaf into master May 29, 2019
@JonRowe JonRowe deleted the myron/fix-define-derived-metadata-cascades branch May 29, 2019 09:33
@JonRowe
Copy link
Member

JonRowe commented May 29, 2019

Thanks @myronmarston!

JonRowe pushed a commit that referenced this pull request Jun 13, 2019
For example:

```
RSpec.configure do |c|
  c.define_derived_metadata(:elasticsearch) do |meta|
    meta[:vcr] = true
  end

  c.define_derived_metadata(:vcr) do |meta|
    meta[:retries] = 2
  end
end
```

With this configuration, an example or group tagged with `:elasticsearch`
should get tagged with `:vcr` as well, which in turn should add
`retries: 2` metadata to the example or group. Before this change,
this did not work properly, because we did look to see if additional
metadata blocks should apply after applying them once.
MatheusRich pushed a commit to MatheusRich/rspec-core that referenced this pull request Oct 30, 2020
For example:

```
RSpec.configure do |c|
  c.define_derived_metadata(:elasticsearch) do |meta|
    meta[:vcr] = true
  end

  c.define_derived_metadata(:vcr) do |meta|
    meta[:retries] = 2
  end
end
```

With this configuration, an example or group tagged with `:elasticsearch`
should get tagged with `:vcr` as well, which in turn should add
`retries: 2` metadata to the example or group. Before this change,
this did not work properly, because we did look to see if additional
metadata blocks should apply after applying them once.
yujinakayama pushed a commit to yujinakayama/rspec-monorepo that referenced this pull request Oct 6, 2021
…spec/rspec-core#2630)

For example:

```
RSpec.configure do |c|
  c.define_derived_metadata(:elasticsearch) do |meta|
    meta[:vcr] = true
  end

  c.define_derived_metadata(:vcr) do |meta|
    meta[:retries] = 2
  end
end
```

With this configuration, an example or group tagged with `:elasticsearch`
should get tagged with `:vcr` as well, which in turn should add
`retries: 2` metadata to the example or group. Before this change,
this did not work properly, because we did look to see if additional
metadata blocks should apply after applying them once.

---
This commit was imported from rspec/rspec-core@61a9434.
yujinakayama pushed a commit to yujinakayama/rspec-monorepo that referenced this pull request Oct 6, 2021
…spec/rspec-core#2630)

For example:

```
RSpec.configure do |c|
  c.define_derived_metadata(:elasticsearch) do |meta|
    meta[:vcr] = true
  end

  c.define_derived_metadata(:vcr) do |meta|
    meta[:retries] = 2
  end
end
```

With this configuration, an example or group tagged with `:elasticsearch`
should get tagged with `:vcr` as well, which in turn should add
`retries: 2` metadata to the example or group. Before this change,
this did not work properly, because we did look to see if additional
metadata blocks should apply after applying them once.

---
This commit was imported from rspec/rspec-core@e0ecbaf.
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.

None yet

2 participants