Skip to content

Rails transactional database cleanup #531

@BrianHawley

Description

@BrianHawley

🤔 What's the problem you're trying to solve?

Rails 5.1+ added code that its test framework hooks into that makes transactional database cleanup thread-safe (there were bugs in the mysql adapter that were fixed in 6.0, but those are patchable). DatabaseCleaner style transactional cleanup is not thread-safe, even with the shared_connection hook that cucumber-rails uses already. However, ActionDispatch::IntegrationTest has fixture support and lifecycle hooks that Rails hooks into, which does let you do transactional database cleanup in a thread-safe way, and World inherits from ActionDispatch::IntegrationTest, so those lifecycle hooks are available as long as the World runtime instance is accessible.

Another advantage of using this solution would be to make it possible to not refer to the database_cleaner gem at all when you aren't using one of the database strategies that doesn't require it, including the null strategy and the rails transactional strategy.

The big problem is that the World instance is not made available to the Strategy instance. I came up with a workaround for this, which I will show below. However, the World instance is available in the Before and After hook blocks, so it's possible to make it available to the Strategy instance.

✨ What's your proposed solution?

First of all, it's worth emphasizing that I am not requesting that other people do the work. I am just making the request here so I have a place to ask for advice.

This has multiple changes, but we should be able to make them while supporting backwards-compatible behavior:

  • Don't require the files in lib/cucumber/rails/database unless and until their strategies are chosen. Maybe autoload?
  • Delay calling default_strategy! until you need to use the strategy for the first time, if it hasn't been defined yet. This will prevent the :truncation strategy code, along with DatabaseCleaner, from being loaded if it's not needed.
  • Move all of the ActiveRecord::Base shared_connection patch code from lib/cucumber/rails/hooks/active_record.rb to lib/cucumber/rails/database/shared_connection_strategy.rb, so it doesn't get loaded when it doesn't need to be.
  • Move the require 'cucumber/rails/hooks/database_cleaner' from lib/cucumber/rails/hooks.rb to the just the strategy files that need to use DatabaseCleaner at all. If NullStrategy or Rails transactional cleanup is used, we shouldn't even attempt to require DatabaseCleaner and shouldn't even log an error if the gem isn't installed.
  • Make Cucumber::Rails::Database before_js, before_non_js, and after all take a scenario parameter.
  • In the Before and After hooks in lib/cucumber/rails/hooks/active_record.rb, pass in self as a parameter to the calls to those before_js, before_non_js, and after methods.

Now at this point we have two alternatives.

In the first:

  • In the Cucumber::Rails::Database before_js and before_non_js methods, call scenario.setup_fixtures before calling the @strategy before_js or before_non_js methods, and call scenario.teardown_fixtures before the @strategy after method.
  • If the Rails version is 5.1+, just default to telling Rails to use transactional fixtures, and use NullStrategy because it doesn't need to do anything more.
  • If the Rails version is < 5.1, you can use SharedConnectionStrategy if someone picks :transactional, using the old somewhat but not really thread-safe integration code.
  • This method is simpler and more thorough, but might be considered a breaking change. For one thing, it probably won't work with the below patch workaround installed because it will call the lifecycle methods more than once in the same lifecycle (at the moment, this only affects me). For another thing, I haven't determined whether there are occasions when you wouldn't want to call those lifecycle methods, so I'd have to study the code again; I think that they're basically noops if you don't have Rails transactional fixtures configured, but I need to check what else hooks into this.

In the second:

  • In the Cucumber::Rails::Database before_js, before_non_js, and after methods, check the arity of the corresponding methods of the @strategy value and pass in the scenario value as a parameter if the arity isn't 0.
  • Have a RailsStrategy that calls scenario.setup_fixtures in before_js and before_non_js, and calls scenario.teardown_fixtures in after.
  • If someone picks :transactional, use RailsStrategy for Rails 5.1+, and SharedConnectionStrategy for earlier versions.
  • This method has the advantage of being compatible with the below patch, and not a breaking change. The disadvantage is that the lifecycle methods aren't called in other scenarios.

⛏Have you considered any alternatives or workarounds?

This works (I put it in features/support/database.rb):

# frozen_string_literal: true

module Cucumber
  module Rails
    module Database
      # Manage persistent state during the Cucumber run.
      class OurStrategy < Strategy
        # Cucumber creates a Cucumber::Runtime but doesn't expose a reference
        # to it. That runtime has a support_code, which has a registry, which
        # is the only thing with a reference to the World that is used as a
        # context for the tests. However, even though that test context is
        # based on ActionDispatch::IntegrationTest, Cucumber doesn't call any
        # of the lifecycle methods for that context object; unfortunately this
        # includes the setup and teardown of thread-safe database connections.
        # Cucumber's own shared connections aren't properly thread-safe, so we
        # would prefer to use the ones managed by Rails.
        #
        # However, even though Cucumber makes the test context available to
        # hooks, including the hooks that call the database cleanup code, it
        # doesn't pass a reference to that context to the database cleaner,
        # so we can't call the lifecycle methods from the database cleaner.
        # Because of this, we have to track the current_world with patches.

        # Thread-local copy of the current_world (test context):
        thread_cattr_accessor :current_world, instance_writer: false

        # Patch for the registry to update our copy of its current_world:
        module RegistryAndMorePatch
          def begin_scenario(test_case)
            super
            Cucumber::Rails::Database::OurStrategy.current_world = current_world
          end

          def end_scenario
            super
            Cucumber::Rails::Database::OurStrategy.current_world = current_world
          end
        end
        Cucumber::Glue::RegistryAndMore.prepend(RegistryAndMorePatch)

        # Our database cleaner lifecycle methods:

        def before_js
          # Rails prepares the ActiveRecord databases in setup_fixtures.
          current_world.setup_fixtures
        end

        def before_non_js
          # Rails prepares the ActiveRecord databases in setup_fixtures.
          current_world.setup_fixtures
        end

        def after
          # Rails cleans up the ActiveRecord databases in teardown_fixtures.
          current_world.teardown_fixtures

          # Clean up other resources
          ::Rails.cache.clear
          # ... other cleanups that I have been doing here, which are specific to our code.
        end
      end
    end
  end
end

# We're doing our own cleanup instead of disabling cleanup because Rails only cleans up the ActiveRecord databases.
Cucumber::Rails::Database.javascript_strategy = Cucumber::Rails::Database::OurStrategy

# cucumber-rails requires DatabaseCleaner even if it isn't being used, and hooks into it.
# So, we disable DatabaseCleaner with its NullStrategy.
DatabaseCleaner.strategy = DatabaseCleaner::NullStrategy.new

I've been using this solution for 2 years. It works, and is as thread-safe as Rails transactional cleanup in rspec, test::unit, minitest, etc.

Additional context

This means that World would need to continue to inherit from ActionDispatch::IntegrationTest, which means that #505 would need to either be significantly changed or canceled altogether.

The biggest challenge I've run into on this is figuring out how to phrase a test in your test suite that would verify that DatabaseCleaner has not been loaded or even required, since that's one of the main goals of these changes, without breaking any other tests of the strategies that use DatabaseCleaner. That's going to need some thought, which I haven't had the time for until now. I'm hoping that your feature tests can execute an external instance of cucumber with different settings, so I can verify the effect of those settings. I would have done this ages ago if it wasn't for that issue.

I'd like to try to make this a less-breaking change, so it can get in as soon as possible, preferably in version 2.5. It would be a disservice to put this off until a cucumber-rails 3.x version, unless you have such a version planned in the near future.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions