Problem/Motivation

This flaw was introduced in #2125717: Migrate in core: patch #1 and also not fixed in #2427335: Combine legacy Source class into SourcePluginBase.

\Drupal\migrate\Plugin\migrate\source\SourcePluginBase::count() has the infrastructure to enable caching of source counts.

But since \Drupal\migrate\Plugin\migrate\source\SqlBase::count() overrides \Drupal\migrate\Plugin\migrate\source\SourcePluginBase::count(), none of that infrastructure can be used by SqlBase-based @MigrateSource plugins.

Worse: the vast majority of @MigrateSource plugins are subclasses of SqlBase, meaning it the vast majority of @MigrateSource plugins cannot be made cacheable.

To make these cacheable today, you'd have to patch them.

To make a migration have a cacheable source count, all you need to do is specify cache_counts: true under the migration definition's source key. Because of #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id., you also need to specify cache_key.

Also, the cache key is based only on the plugin ID, which is not sufficiently unique. "The same source plugin may be used in multiple migrations with different configuration which affect the source count. For example, derived D7 node migrations will use the same d7_node source but with different bundle configurations to retrieve each content type, and each content type will have a different count. For example, if multiple migrations use the contrib 'url' plugin and enable cache_counts without setting a cache_key, they will all interfere with each other". From mikelutz in this Issue Summary.

This issue will unblock

  1. in core: #3190818: Allow source counts to be cached: implement ::doCount() instead of ::count()
  2. in contrib: metatag: #3190804: Allow source counts to be cached: implement ::doCount() instead of ::count()
  3. in contrib: media_migration: #3190800: Allow source counts to be cached: implement ::doCount() instead of ::count()
  4. in contrib: pathauto: #3190980: Allow source counts to be cached: implement ::doCount() instead of ::count()
  5. in contrib: redirect: #3162128-6: Derive path redirect migrations per entity type
  6. in contrib: location_migration: #3192657: EntityLocationFieldInstance & LocationFieldStorage do not count source items correctly
  7. in contrib: bean_migrate: #3199294: Remove source count caching workaround when core issue is resolved

Steps to reproduce

Proposed resolution

  1. Update SqlBase to not override ::doCount() instead of ::count(). This allows it to use the parent ::count() implementation, and hence enables it to take advantage of source count caching.
  2. Update SourcePluginBase::count() to not read from or write to the cache when cache_counts != true in the migration definition.
  3. Create a unique cache key per source configuration using either the configure cache_key or plugin_in followed by a hash of the source configuration.
  4. Include the changes from #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id..
  5. Add MemoryCounterBackendFactory so that test can use MemoryCounterBackend more easily.

Remaining tasks

  • Issue summary update. See #8.5.
  • Add a test and a test-only patch. See #15#21.
  • Updates requested in #8.

User interface changes

None

API changes

None

Data model changes

None

Release notes snippet

CommentFileSizeAuthor
#70 interdiff-64-69.txt590 bytesnickdickinsonwilde
#69 3190815-69.patch17.53 KBnickdickinsonwilde
#60 3190815-60.patch17.17 KBquietone
#60 interdiff-56-60.txt702 bytesquietone
#56 3190815-56.patch17.12 KBquietone
#56 interdiff-55-56.txt6.69 KBquietone
#55 3190815-55.patch11.22 KBquietone
#55 interdiff-48-55.txt4.99 KBquietone
#48 3190815-48.patch11.62 KBquietone
#48 interdiff-44-48.txt1.95 KBquietone
#44 3190815-44.patch11.61 KBwim leers
#44 3190815-44-test-only.patch8.3 KBwim leers
#44 interdiff.txt3.06 KBwim leers
#43 3190815-43.patch9.8 KBwim leers
#43 3190815-43-test-only.patch6.49 KBwim leers
#43 interdiff.txt1003 byteswim leers
#42 3190815-42.patch9.83 KBquietone
#42 interdiff-35-42.txt11.16 KBquietone
#35 sql_base_source_plugin_cache_counts_support-3190815-35.patch9.23 KBwim leers
#35 sql_base_source_plugin_cache_counts_support-3190815-35-test-only.patch5.92 KBwim leers
#35 interdiff.txt5.29 KBwim leers
#28 sql_base_source_plugin_cache_counts_support-3190815-28.patch9.06 KBwim leers
#28 interdiff.txt3.01 KBwim leers
#26 sql_base_source_plugin_cache_counts_support-3190815-22-test-only.patch5.28 KBwim leers
#22 sql_base_source_plugin_cache_counts_support-3190815-22.patch7.84 KBwim leers
#22 interdiff.txt1.89 KBwim leers
#21 sql_base_source_plugin_cache_counts_support-3190815-21.patch7.79 KBwim leers
#21 interdiff.txt877 byteswim leers
#18 sql_base_source_plugin_cache_counts_support-3190815-18.patch7.79 KBwim leers
#18 interdiff.txt382 byteswim leers
#17 sql_base_source_plugin_cache_counts_support-3190815-17.patch7.48 KBwim leers
#17 interdiff.txt3.36 KBwim leers
#16 sql_base_source_plugin_cache_counts_support-3190815-16.patch6.47 KBwim leers
#16 interdiff.txt2.36 KBwim leers
#15 sql_base_source_plugin_cache_counts_support-3190815-13.patch6.68 KBwim leers
#15 interdiff.txt4.53 KBwim leers
#11 sql_base_source_plugin_cache_counts_support-3190815-11.patch2.55 KBwim leers
#11 interdiff.txt2.1 KBwim leers
#7 sql_base_source_plugin_cache_counts_support-3190815-7.patch1.41 KBwim leers
#7 interdiff.txt536 byteswim leers
#4 sql_base_source_plugin_cache_counts_support-3190815-4.patch1.41 KBwim leers
#3 sql_base_source_plugin_cache_counts_support.patch1.41 KBwim leers
#2 sql_base_source_plugin_cache_counts_support.patch545 byteswim leers

Comments

Wim Leers created an issue. See original summary.

wim leers’s picture

Status: Active » Needs review
StatusFileSize
new545 bytes
wim leers’s picture

And while we're at it, SourcePluginBase::count() is actually also broken: it always caches the source count. This makes no sense either. Plus, it results in unnecessary cache I/O.

The documentation that #2862670: Add documentation to SourcePluginBase source plugin added is crystal clear:

 * - cache_counts: (optional) If set, cache the source count.
 * - cache_key: (optional) Uniquely named cache key used for cache_counts.

That's not how SourcePluginBase behaves today.

So it's no wonder that nobody ever complained about this: it's very broken today, alas.

wim leers’s picture

Not sure what happened there. Rebased.

wim leers’s picture

Issue summary: View changes
wim leers’s picture

+++ b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php
@@ -385,7 +385,7 @@ protected function fetchNextBatch() {
-  public function count($refresh = FALSE) {
+  public function doCount() {

Should be protected.

I think the fact that #4 is green proves there is no BC break, especially considering I even accidentally forgot to change public to protected! 🙈

wim leers’s picture

benjifisher’s picture

Issue summary: View changes
Status: Needs review » Needs work
Issue tags: +Needs issue summary update, +Needs tests, +Novice
  1. Why is this issue marked Major?

  2. This override needs a doc block explaining what it actually does:

     +++ b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php
     @@ -385,7 +385,7 @@ protected function fetchNextBatch() {
        /**
         * {@inheritdoc}
         */
     -  public function count($refresh = FALSE) {
     +  protected function doCount() {
          return (int) $this->query()->countQuery()->execute()->fetchField();
        }
  3. Update the comment:

     +++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
     @@ -450,13 +450,17 @@ public function count($refresh = FALSE) {
     ...
          // If a refresh is requested, or we're not caching counts, ask the derived
          // class to get the count from the source.
     -    if ($refresh || !$this->cacheCounts) {
     +    if ($refresh) {
            $count = $this->doCount();
            $this->getCache()->set($this->cacheKey, $count);
          }
  4. It may be out of scope for this issue, but I would like to clean this up:

         if ($refresh) {
           $count = $this->doCount();
           $this->getCache()->set($this->cacheKey, $count);
         }
         else {
           // Caching is in play, first try to retrieve a cached count.
           $cache_object = $this->getCache()->get($this->cacheKey, 'cache');
           if (is_object($cache_object)) {
             // Success.
             $count = $cache_object->data;
           }
           else {
             // No cached count, ask the derived class to count 'em up, and cache
             // the result.
             $count = $this->doCount();
             $this->getCache()->set($this->cacheKey, $count);
           }
         }
         return $count;
       }

    We can avoid a couple of lines of duplicated code:

         if (!$refresh) {
           // ...
           $cache_object = ...
           if (...) {
             // ...
             return $cache_object->data;
           }
         }
    
         // No cached count, or refresh requested. Ask the derived class to count
         // 'em up, and cache the result.
         $count = $this->doCount();
         $this->getCache()->set($this->cacheKey, $count);
         return $count;
  5. It is OK to expand the issue scope as in #3, but we need to update the "Proposed resolution" in the issue summary, and maybe even update the title. I am adding the tag for an IS update.

  6. A bugfix like this needs an automated test, and I like to see a test-only patch.

Some of these are Novice tasks, so I am adding that tag.

wim leers’s picture

Issue summary: View changes
wim leers’s picture

Title: Impossible to enable source count caching for all SqlBase-based source plugins (i.e. most of them) » Source count caching broken: impossible to enable source count caching for SqlBase-based source plugins (plus, unneeded cache I/O)
Issue summary: View changes
Issue tags: -Needs issue summary update, -Novice

#8:

  1. Because A) the performance impact is enormous, B) the migration system does not behave as documented, C) it has a systemic negative impact
  2. Why does it need that now if it did not need it before? This is not new code.
  3. Agreed!
  4. Agreed!
  5. Done
  6. Agreed!

None of this is Novice IMO because it requires knowing the migration system thoroughly.

#8.3, #8.4 and #8.6 still need to be addressed.

wim leers’s picture

Issue summary: View changes
Status: Needs work » Needs review
StatusFileSize
new2.1 KB
new2.55 KB

Addressed #8.3 + #8.4.

(FYI I specifically did not do those things in #7 to keep the diff as minimal as possible.)

That leaves only test coverage.

wim leers’s picture

Issue summary: View changes
benjifisher’s picture

Issue summary: View changes

8.1: Thanks for the explanation.

8.2: Currently, the API docs for SqlBase::count() say,

Gets the source count.

Return a count of available source records, from the cache if appropriate. Returns -1 if the source is not countable.

I think that is accurate. (Maybe not the part about "from the cache", but this issue will fix that.) After the current patch, the API docs for SqlBase::doCount() would read,

Gets the source count checking if the source is countable or using the iterator_count function.

and that is not accurate.

8.3: Obsolete since you decided to do 8.4.

8.4: The change looks good. I am surprised it saved only one line. We could save two more by removing the else after if (...) { return ... }. That is the usual style in Drupal, even though many good programmers hate it.

Back to NW for 8.2 and 8.6.

benjifisher’s picture

Status: Needs review » Needs work
wim leers’s picture

I just discovered #2598670: Source count caching does not work for SQL sources… which is essentially arguing for the same change, but over five years ago. It sadly got sidelined and was last updated ~1.5 year ago.

Over there, @mikelutz wrote test coverage at #2598670-30: Source count caching does not work for SQL sources that I can reuse here! 🥳

That issue was blocked on #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. — which appears to be a duplicate of #2751829: Default source plugin cache key is insufficiently unique 😬. I disagree that this functionality/bug fix needs to be blocked on that, because if you're not specifying cache_counts: true in your migration definition, then none of this kicks in anyway. If this patch lands, you'll need to specify cache_key too if you do not want to run into cache collisions. But at least that's better than not being able to turn this on at all and potentially getting blocked on bad source count performance (like I am).

wim leers’s picture

  1. +++ b/core/modules/migrate/tests/modules/migrate_sqlbase_count_cache_test/migrate_sqlbase_count_cache_test.info.yml
    @@ -0,0 +1,7 @@
    +core: 8.x
    

    This is not gonna work in D9. #15 will fail because of this.

  2. +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceCountCacheTest.php
    @@ -0,0 +1,62 @@
    +  public static $modules = ['migrate_sqlbase_count_cache_test'];
    

    This should be protected.

  3. +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceCountCacheTest.php
    @@ -0,0 +1,62 @@
    +    $plugin = $this->getPlugin([]);
    ...
    +    $cached_count = \Drupal::cache('migrate')->get($cache_key, 'cache')->data;
    +    $this->assertSame($count, $cached_count);
    

    This does not make sense.

    We're not specifying cache_counts in the source plugin configuration, so it should not get cached. #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. should not have passed tests. It probably did not run? I can't tell though, because the detailed test results are no longer available.

wim leers’s picture

+++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceCountCacheTest.php
@@ -0,0 +1,70 @@
+        'source_configuration' => [
+          'cache_counts' => TRUE,
+        ],

In #16, this is the only case that is tested. Let's test all other cases too.

wim leers’s picture

Well THAT is unfortunate, #16 😬Tests did not run due to a simple pre-existing word in a test-only module 🤯🤦‍♂️ This is rather counterproductive.

wim leers’s picture

#13.8.6: in progress 🤓

#13.8.2: solved in #15, by bringing over @mikelutz' work 🤓

wim leers’s picture

wim leers’s picture

8.4: The change looks good. I am surprised it saved only one line. We could save two more by removing the else after if (...) { return ... }. That is the usual style in Drupal, even though many good programmers hate it.

Made your suggested change.

Plus some clarifications.

wim leers’s picture

Issue summary: View changes
wim leers’s picture

Ahhh … the classic "cross-posting causes the node to get unpublished" bug on d.o 😬

mikelutz’s picture

That issue was blocked on #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. — which appears to be a duplicate of #2751829: Default source plugin cache key is insufficiently unique 😬. I disagree that this functionality/bug fix needs to be blocked on that, because if you're not specifying cache_counts: true in your migration definition, then none of this kicks in anyway. If this patch lands, you'll need to specify cache_key too if you do not want to run into cache collisions. But at least that's better than not being able to turn this on at all and potentially getting blocked on bad source count performance (like I am).

The reason this and/or #2598670: Source count caching does not work for SQL sources needs to be blocked on #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. is because if you have an existing set of migrations using the same sql plugin but different configurations, and you've set `cache_counts: true` thinking it was going to help you cache counts and give you a performance boost, then due to the combination of the two bugs, your counts at least work, despite not actually being cached.

If you fix this here without fixing the default cache_key hash, then you are breaking counts on those previously working migrations, which isn't acceptable.

wim leers’s picture

#21 and #22 are passing MigrateSqlSourceCountCacheTest, so time to upload a test-only patch.


FYI …

+++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceCountCacheTest.php
@@ -0,0 +1,96 @@
+      'cached source count, auto-generated cache key' => $source_data_and_expected_result + [
+        'source_configuration' => [
+          'cache_counts' => TRUE,
+        ],
+        'expected_cache_id' => hash('sha256', 'sql_count_cache'),
+      ],

This is where #2751829: Default source plugin cache key is insufficiently unique or #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. (whichever one of those duplicates we end up continuing) would be able to do something like

        'source_configuration' => [
          'foo' => TRUE          
          'cache_counts' => TRUE,
        ],
        'expected_cache_id' => 'ID-ONE,

and

        'source_configuration' => [
          'foo' => FALSE,          
          'cache_counts' => TRUE,
        ],
        'expected_cache_id' => 'ID-TWO,

It'd actually be trivial to implement in this issue 🤓

EDIT: did that in #28!

wim leers’s picture

#25:

because if you have an existing set of migrations using the same sql plugin but different configurations, and you've set `cache_counts: true` thinking it was going to help you cache counts and give you a performance boost, due to the combination of the two bugs, your counts work, despite not actually being cached.

Ahhh, of course! 😬

Thanks so much for chiming in with that, it's obvious in hindsight! 🙈

wim leers’s picture

So … given that #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. (and its duplicate predecessor) is a hard blocker and got completely sidelined, I propose including that bugfix in this issue/patch. With the test coverage work I did in #17, it becomes absolutely trivial!

This interdiff takes a lot of inspiration from #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. — again by the awesome @mikelutz, whom I've already credited earlier — but changes the generated cache key to not just be a hash, but the source plugin ID followed by a hash.

Can't wait to see what you think about this, @benjifisher & @mikelutz! 🤓🙏

mikelutz’s picture

LGTM. I haven't tested it, but at a glance the approach gets a +1 from me, along with fixing the cache_key here.

Looks like a good chunk of my code made it into this patch, so I'll leave the RTBC to others.

wim leers’s picture

Looks like a good chunk of my code made it into this patch, so I'll leave the RTBC to others.

Indeed! Building upon your work was super beneficial: I independently made some of the same observations, but most importantly, being able to lift your test coverage into this patch was a HUGE time saver!

benjifisher’s picture

Status: Needs review » Needs work

@Wim Leers, @mikelutz:

My first thought would be to keep the scope narrow and fix one problem at a time, starting with the older issue. But if you both agree that we should do it all here, then I will go along and see what the core commiters think.

  1. What are the guidelines for adding a word to the dictionary instead of skipping spell check on a particular line?

     +++ b/core/misc/cspell/dictionary.txt
     @@ -1470,6 +1470,7 @@ specialchars
     ...
     +sqlbase

    The spell checker complains about migrate_sqlbase_count_cache_test (the module name) in the namespace line? That seems like a problem … a problem for another day.

  2. +1 for moving this to the constructor:

     +++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
     @@ -199,7 +200,9 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
     ...
     -    $this->cacheKey = !empty($configuration['cache_key']) ? $configuration['cache_key'] : NULL;
     +    if ($this->cacheCounts) {
     +      $this->cacheKey = !empty($configuration['cache_key']) ? $configuration['cache_key'] : $plugin_id . '-' . hash('sha256', Json::encode($configuration));
     +    }

    Can we simplify this using ??? Does it make any difference whether we concatenate the plugin ID (as here) or add it to the array before applying Json::encode()? That is, Json::encode(['plugin_id' => $plugin_id] + $configuration).

  3. I still think we should update the doc block (#8.2). See #13.

     +++ b/core/modules/migrate/src/Plugin/migrate/source/SqlBase.php
     @@ -385,7 +385,8 @@ protected function fetchNextBatch() {
        /**
         * {@inheritdoc}
         */
     -  public function count($refresh = FALSE) {
     +  protected function doCount() {
     +    // Uses countQuery() because it is faster than the parent implementation.
  4. Nit: it is valid YAML, but the usual practice is to indent the dependency list:

     +++ b/core/modules/migrate/tests/modules/migrate_sqlbase_count_cache_test/migrate_sqlbase_count_cache_test.info.yml
     @@ -0,0 +1,6 @@
     +type: module
     +name: Migrate SqlBase count cache test.
     +description: 'Provides a source plugin to test that counts are cached in SQL sources.'
     +package: Testing
     +dependencies:
     +- drupal:migrate

    Also, the description should have ending punctuation but not the name.

  5. It seems like a cheat to use source_module = "migrate", but I guess it is good enough for the test.

     +++ b/core/modules/migrate/tests/modules/migrate_sqlbase_count_cache_test/src/Plugin/migrate/source/SqlCountCache.php
     @@ -0,0 +1,44 @@
     ...
     +/**
     + * Source plugin for SqlBase count cache test.
     + *
     + * @MigrateSource(
     + *   id = "sql_count_cache",
     + *   source_module = "migrate",
     + * )
     + */
     +class SqlCountCache extends SqlBase {
  6. We do not translate strings in test classes, but I am not sure about the policy for test modules. Do we want to use t() here?

     +  public function fields() {
     +    return [
     +      'id' => t('Id'),
     +    ];
     +  }
  7. Do we want to say that this test @covers the test class, not SqlBase or SqlBase::count()?

     +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceCountCacheTest.php
     @@ -0,0 +1,103 @@
     ...
     +/**
     + * Tests SqlBase source count caching.
     + *
     + * @covers \Drupal\migrate_sqlbase_count_cache_test\Plugin\migrate\source\SqlCountCache
  8. Funny, this came up on another issue I was reviewing today, #3189064: Migration sql source plugins can not be serialised because of reference to the database connection. Compare with Drupal\Tests\migrate\Kernel\Plugin\MigrationPluginListTest, which sets up a connection. Do we have an issue to make this more convenient?

     +    // Since we don't yet inject the database connection, we need to use a
     +    // reflection hack to set it in the plugin instance.
     +    $reflector = new \ReflectionObject($plugin);
     +    $property = $reflector->getProperty('database');
     +    $property->setAccessible(TRUE);
     +    $property->setValue($plugin, $this->getDatabase($source_data));
  9. This looks like a problem:

     +      // Compute the default cache key, to ensure nothing was written to cache.
     +      // @see \Drupal\migrate\Plugin\migrate\source\SourcePluginBase::__construct()
     +      $cache_key = hash('sha256', $plugin->getPluginId());
     +      $this->assertFalse(\Drupal::cache('migrate')->get($cache_key, 'cache'));

    That is not the right cache key! If we keep the reflection hack, then we could add a protected method to compute the cache key and make it accessible in this test. Maybe it is not worth that much trouble. Is there a good way to test that we have the right key? Perhaps add an elseif clause after if ($expected_cache_key) that checks for $source_plugin_configuration['cache_counts'], and add a corresponding entry to the data provider.

  10. Since we are using the same source data, expected result, and expected count, wouldn’t it be simpler to define these in the test method instead of the data provider? Come to think of it, why do have $expected_results if we never reference it?

mikelutz’s picture

My first thought would be to keep the scope narrow and fix one problem at a time, starting with the older issue. But if you both agree that we should do it all here, then I will go along and see what the core commiters think.

We originally decided to separate the issues around #2598670-33: Source count caching does not work for SQL sources, but I've wanted them both fixed for well over a year now, and those issues stalled. If we can fix them together here and close all the other issues, and the FMs don't object, I think we should just get it done. They are both relatively trivial fixes, they are definitely intertwined and the code has been written, just gotten stale. Lets just address #33 and get it done.

wim leers’s picture

#34:

They are both relatively trivial fixes, they are definitely intertwined and the code has been written, just gotten stale. Lets just address #33 and get it done.

Amen.

#33:

  1. It was complaining about the word in both the .info.yml and the class — see the #15 test results. There's loads of classnames in dictionary.txt already, for example browsertestbase. Furthermore, this also matches the guidelines in https://www.drupal.org/node/3122084: if there's an unrecognized English word or a correctly spelled technical term, add it to the Drupal dictionary file:
  2. 👍 Done. We don't want to JSON-encode the plugin ID too, because then we'll also hash it, and legibility will suffer. See my review of @mikelutz's original code at #3092227-14: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id., where I proposed this exact pattern and he +1'd it in #3092227-16: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id..
  3. 👍 Done
  4. 👍 Done
  5. 👍 Fixed, this was unnecessary.
  6. This actually matches other tests, for example \Drupal\migrate_high_water_test\Plugin\migrate\source\HighWaterTest::fields().
  7. 👍 We do, but we also want what you said, and more. Done.
  8. No idea, but addressing this is definitely out of scope. Not absolutely everything needs to be DRY, especially tests don't need to be. 🤓
  9. 💯 Good catch! In doing so, I made @mikelutz' test coverage also much more thorough: it now verifies a cache miss prior to calling ::count() and a cache hit afterwards.
  10. I had the exact same questions! This is sadly not by choice but required because this is using \Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase, which forces that structure upon us. The entire data provider structure is dictated by this. Only that fifth expected_cache_id parameter is "custom/extra" here compared to the test base class.

benjifisher’s picture

Status: Needs review » Needs work

In #33, I thought it was already clear that you both approved of this approach. I was not asking for confirmation. I am sorry I was ambiguous.

Thanks for all the fixes in #35!

The test-only patch in #35 has two failures and an error. The failures are because we test that the cache key is defined in the constructor, which is one of the changes in this issue. So these failures do not prove that we have fixed anything. The error is caused by trying to access the data property after a cache miss. Since we are also changing the cache key, the failure does not prove anything either. We do not need a failing test, but if we want to provide one, it will have to be different from the test-only patch.

  1. This is much better. Now that $cacheKey is set in the constructor (#33.2), we can test it here.

     +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceCountCacheTest.php
     @@ -0,0 +1,110 @@
     ...
     +    // Verify the the computed cache key.
     +    $property = $reflector->getProperty('cacheKey');
     +    $property->setAccessible(TRUE);
     +    $this->assertSame($expected_cache_key, $property->getValue($plugin));
  2. After asserting $expected_count == $count, we could assert $expected_count == $cached_count instead of asserting that two computed values are equal:

     +    $count = $plugin->count();
     +    $this->assertSame($expected_count, $count);
     +
     +    // Cache hit afterwards.
     +    if ($expected_cache_key) {
     +      $cached_count = \Drupal::cache('migrate')->get($expected_cache_key, 'cache')->data;
     +      $this->assertSame($count, $cached_count);
     +    }
  3. Same snippet: you removed the else clause, checking for no caching after $plugin->count(). I guess the point is what I said in #33.9: checking any particular cache key does not really tell us anything. Is there a way to test that the cache is completely empty, or that it has no entries with the known prefix?

  4. Follow-up to #33.10 and the reply in #35. In MigrateSourceTestBase, we declare that providerSource() is the data provider for testSource(). Now we are using it as the provider for another test method. That seems wrong.

    I do not like the pattern of defining test methods in base classes. I think I see a follow-up issue.

    If we keep the current providerSource(), then I think we should add @cover comments for everything tested by testSource(). Maybe the right way to do that would be to declare testSource(), add the comments there, and just call the parent method.

    A simpler idea is to have providerSource() return an empty array and create a separate provider for the new test.

benjifisher’s picture

Re #37.3: I guess we would have to replace the cache provided by \Drupal->cache('migrate') with an implementation that lets us check whether any cache keys starting with $plugin_id have been created. That seems like a lot of work just to verify that something has not been cached.

quietone’s picture

quietone’s picture

Issue tags: -migrate-d7-d8

This is not specific to Drupal 7 sources.

wim leers’s picture

quietone’s picture

Status: Needs work » Needs review
StatusFileSize
new11.16 KB
new9.83 KB

37.4 I agree this seems wrong. The new test is an outlier in all the source plugin tests in that it has it's own testSource method. I'd much rather keep to the existing way of doing these tests. Instead of adding a new testSource I think we should test the caching in the existing \Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase::testSource in an if block so existing tests will not test caching. If we do that the block of 'reflection' code is not repeated and it makes it easier for contrib to test caching.

The existing \Drupal\Tests\migrate\Kernel\MigrateSourceTestBase::testSource has been changed in a similar manner (an if block) to allow testing of high_water and there is an issue to also add testing checkRequirements. With that in mind this seems like a reasonable idea.

I know benjifisher does not like test methods in test base classes but that is not an issue to resolved here (I should add that I am rather fond of \Drupal\Tests\migrate\Kernel\MigrateSourceTestBase).

I have made a patch to see what this looks like. I kept the use of strings for the array keys in MigrateSqlSourceCountCacheTest::providerSource but changed them to be consistent with the parameter names in testSource. Moved the new test to the directory path where source plugin tests normally live. Changed the name in the test module to avoid the dictionary change (I don't think that will be accepted).

wim leers’s picture

StatusFileSize
new1003 bytes
new6.49 KB
new9.8 KB

#42: looks good — just two nits:

  1. +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
    @@ -67,12 +67,14 @@ protected function getDatabase(array $source_data) {
    +   * @param mixed $expected_cache_id
    +   *   (optional) The cache ID.
    
    @@ -82,6 +84,25 @@ public function testSource(array $source_data, array $expected_data, $expected_c
    +      // Verify the the computed cache key.
    +      $property = $reflector->getProperty('cacheKey');
    ...
    +      $this->assertSame($expected_cache_id, $property->getValue($plugin));
    

    Cache ID vs key. This is not consistent right anymore now.

  2. +++ b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php
    @@ -0,0 +1,82 @@
    +      'uncached source count' => $data + [
    +          'configuration' => [],
    +          'high_water' => NULL,
    +          'expected_cache_id' => NULL,
    +        ],
    

    Indentation is off here and in all subsequent cases.

Fixed those nits in this reroll.

wim leers’s picture

StatusFileSize
new3.06 KB
new8.3 KB
new11.61 KB

#37:

  • The error is caused by trying to access the data property after a cache miss.

    Addressed in this reroll.

  • #37.1: already addressed by @quietone's #42.
  • #37.2: also addressed.
  • #37.3: We can't this without providing an alternative cache backend implementation. Added that. Fortunately we could leverage some of the pre-existing infrastructure. So: also addressed.
  • #37.4: already addressed by @quietone's #42.

    Now the test-only fix is meaningful and should show 3 failures (3 out of 4 test cases should fail).

The last submitted patch, 43: 3190815-43-test-only.patch, failed testing. View results

The last submitted patch, 44: 3190815-44-test-only.patch, failed testing. View results

wim leers’s picture

@benjifisher to your point, #43's test-only patch fails like this:

There was 1 error:

1) Drupal\Tests\migrate\Kernel\Plugin\source\MigrateSqlSourceCountCacheTest::testSource with data set "cached source count, provided cache key" (array(array(array(1), array(2), array(3), array(4))), array(array(1), array(2), array(3), array(4)), 4, array(true, 'custom_cache_key_here'), null, 'custom_cache_key_here')
Trying to get property 'data' of non-object

/var/www/html/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php:102
/var/www/html/vendor/phpunit/phpunit/src/Framework/TestResult.php:691

--

There were 2 failures:

whereas #44's fails like this:

There were 3 failures:

So I believe your concerns have been addressed. I believe this is ready to be RTBC'd.

quietone’s picture

StatusFileSize
new1.95 KB
new11.62 KB

@Wim Leers, glad you liked the changes in #42.

I missed this.

+++ b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php
@@ -0,0 +1,82 @@
+        'expected_cache_id' => NULL,
...
+        'expected_cache_id' => 'sql_count_cache-dbed2396c230e025663091479993a206441bf1f9ae4e60ebf3b504e4a76ad471',
...
+        'expected_cache_id' => 'sql_count_cache-83c62856dd5afc011f32574bcdc11c595557d629e1d73045e9353df2441ec269',
...
+        'expected_cache_id' => 'custom_cache_key_here',

The parameter name in the method signature is expected_cache_key.

wim leers’s picture

Hey, I missed that too! 😅

wim leers’s picture

Anything left here for this to land? 🤞😊

benjifisher’s picture

Status: Needs review » Needs work
Issue tags: +Needs issue summary update, +Needs change record

It has been a long time since I last looked at this issue, and the patch has changed a lot since then.

I will start a fresh review now, but the issue summary needs an update. At least

Perhaps I am to blame for MemoryCounterBackendFactory: see #37.3 and #38. I have learned (from @alexpott, I think) to be suspicious of classes that are used only in tests. Let's discuss this one openly.

To make a migration have a cacheable source count, all you need to do is specify cache_counts: true under the migration definition's source key. And until #2751829: Default source plugin cache key is insufficiently unique is fixed, you'll also need to specify cache_key.

Is that enough to justify a change record? I will add the issue tag for that. If you disagree, then you can remove it.

NW for the issue summary update.

benjifisher’s picture

  1. I think one of the comments already explains why we use this for the cache key. It will help to add that to the issue summary.

     +++ b/core/modules/migrate/src/Plugin/migrate/source/SourcePluginBase.php
     @@ -215,7 +216,9 @@ public function __construct(array $configuration, $plugin_id, $plugin_definition
     ...
     -    $this->cacheKey = !empty($configuration['cache_key']) ? $configuration['cache_key'] : NULL;
     +    if ($this->cacheCounts) {
     +      $this->cacheKey = $configuration['cache_key'] ?? $plugin_id . '-' . hash('sha256', Json::encode($configuration));
     +    }
  2. This is more complicated than it has to be:

         $count = NULL;
    
         // If a refresh is requested, or we're not caching counts, ask the derived
         // class to get the count from the source.
         if ($refresh || !$this->cacheCounts) {
           $count = $this->doCount();
         }
    
         // If count caching is enabled: read from cache or update it.
         if ($this->cacheCounts) {
           if ($count === NULL) {
             $cache_object = $this->getCache()->get($this->cacheKey, 'cache');
             if (is_object($cache_object)) {
               // Early return for a cache hit.
               return $cache_object->data;
             }
             $count = $this->doCount();
           }
           // No early return, so this was not a cache hit. Either a forced refresh
           // or a cache miss would lead to this point. Either way, update the cache.
           $this->getCache()->set($this->cacheKey, $count);
         }
         return $count;

    I made a little boolean table: the possible T/F values of $this->cacheCounts, $refresh, and whether there is a cache hit. I concluded that we return the cached value in the T/F/T case. In all other cases, we call doCount() and return its value. If $this->cacheCounts is T, then we cache the computed value before returning it. Like this:

         if ($this->cacheCounts && !$refresh) {
           $cache_object = $this->getCache()->get($this->cacheKey, 'cache');
           if (is_object($cache_object)) {
             return $cache_object->data;
           }
         }
         $count = $this->doCount();
         if ($this->cacheCounts) {
           $this->getCache()->set($this->cacheKey, $count);
         }
         return $count;

    That saves a few lines and reduces the maximum level of nesting by 1. I think this version is simple enough that it does not need any code comments, but add some of them back if you disagree.

  3. Can we use string|null for the @param comment?

     +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
     @@ -67,12 +77,14 @@ protected function getDatabase(array $source_data) {
     ...
     +   * @param mixed $expected_cache_key
     +   *   (optional) The expected cache key.
  4. There are a lot of calls to \Drupal::cache('migrate') here:

     @@ -82,6 +94,33 @@ public function testSource(array $source_data, array $expected_data, $expected_c
     ...
     +    if ($expected_cache_key) {
     +      // Verify the the computed cache key.
     +      $property = $reflector->getProperty('cacheKey');
     +      $property->setAccessible(TRUE);
     +      $this->assertSame($expected_cache_key, $property->getValue($plugin));
     +
     +      // Cache miss prior to calling ::count().
     +      $this->assertFalse(\Drupal::cache('migrate')
     +        ->get($expected_cache_key, 'cache'));
     +
     +      $this->assertSame([], \Drupal::cache('migrate')->getCounter('set'));
     +      $count = $plugin->count();
     +      $this->assertSame($expected_count, $count);
     +      $this->assertSame([$expected_cache_key => 1], \Drupal::cache('migrate')->getCounter('set'));
     +
     +      // Cache hit afterwards.
     +      $cache_item = \Drupal::cache('migrate')
     +        ->get($expected_cache_key, 'cache');
     +      $this->assertNotSame(FALSE, $cache_item, 'This is not a cache hit.');
     +      $this->assertSame($expected_count, $cache_item->data);
     +    }
     +    else {
     +      $this->assertSame([], \Drupal::cache('migrate')->getCounter('set'));
     +      $plugin->count();
     +      $this->assertSame([], \Drupal::cache('migrate')->getCounter('set'));
     +    }

    Can we call it just once, before the if block: $cache = \Drupal::cache('migrate')? Or call it $migrate_cache if you think that is clearer. If we add an @var comment, that will help future us understand where getCounter() comes from.

  5. Since the last three parameters for testSource() are optional, wouldn’t it be simpler to leave them off instead of giving them the default values?

     +++ b/core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php
     @@ -0,0 +1,82 @@
     ...
     +    return [
     +      'uncached source count' => $data + [
     +        'configuration' => [],
     +        'high_water' => NULL,
     +        'expected_cache_key' => NULL,
     +      ],
  6. Is that someone’s birthday? If so, you are 30 days older than my daughter. Maybe I can introduce you some day. ;)

     +          'some_source_plugin_configuration_key' => 19920106,

I should look at the failures in the test-only patch, but not at this hour.

Some of the previous discussion is coming back to me. In particular, #31-#33. We are rolling in the fix for #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. into this issue, right?

quietone’s picture

Assigned: Unassigned » quietone

I'll look at this in the next few days

quietone’s picture

Issue summary: View changes

#52.1 Updated the IS.

quietone’s picture

Status: Needs work » Needs review
Issue tags: -Needs issue summary update
StatusFileSize
new4.99 KB
new11.22 KB

#52
2. Nice work to simplify the logic. It looks right to me, and tests are still passing. :-)
3, 4, 5. All fixed.
6. Looking back I see that I added that line but I don't know why I picked that number. Maybe I was playing with https://unix.click/.

Todo: Answer the question in the last paragraph. The cache_key generation is certainly here but the test over there is different.

quietone’s picture

StatusFileSize
new6.69 KB
new17.12 KB

Yes, the test is different. It tests that when the source configuration is different and caching is used the caches are unique and have the correct value. Therefor, I have moved that tests here.

This should finish resolving all the points in #52.

What is to be in the change record?

quietone’s picture

Assigned: quietone » Unassigned

OK, this is ready for more review.

benjifisher’s picture

Assigned: Unassigned » benjifisher
Issue summary: View changes

I am reviewing the patch in #56. For now, I will update the issue summary following #51.

@quietone:

What is to be in the change record?

I am not sure we need one. I suggested it in #51. If we do not need a change record, then we can just remove the tag.

benjifisher’s picture

Assigned: benjifisher » Unassigned
Status: Needs review » Reviewed & tested by the community

@quietone:

Thanks for all the updates based on #52. They look good, except for one minor point:

    +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
    @@ -82,6 +94,32 @@ public function testSource(array $source_data, array $expected_data, $expected_c
    ...
    +    $cache = \Drupal::cache('migrate');
    +    if ($expected_cache_key) {

In #52.4, I said, "If we add an @var comment, that will help future us understand where getCounter() comes from." I will not set the issue to NW for this, but I still think that /** @var MemoryCounterBackend $cache **/ will be helpful to the next person who looks at this test.

quietone’s picture

Status: Reviewed & tested by the community » Needs review
Issue tags: -Needs change record
StatusFileSize
new702 bytes
new17.17 KB

As usual, my attention to detail still needs improvement. This patch adds the inline @var asked for in #52.4 and again in #59.

Like benjifisher I don't see a need for a change record, so removing tag. I am sure someone will add it back if they think otherwise.

benjifisher’s picture

Status: Needs review » Reviewed & tested by the community

@quietone, thanks for the addition! Back to RTBC.

larowlan’s picture

Status: Reviewed & tested by the community » Needs review

Some great work here folks, a few observations/questions

  1. Should we be looking to resolve the existing bug #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id. first and postpone this on that issue? Seems like we may have a scope creep issue here.
  2. +++ b/core/lib/Drupal/Core/Cache/MemoryCounterBackendFactory.php
    @@ -0,0 +1,24 @@
    +class MemoryCounterBackendFactory implements CacheFactoryInterface {
    ...
    +      $this->bins[$bin] = new MemoryCounterBackend();
    

    At the moment MemoryCounterBackend has a todo comment to remove it and use phpunit mocks instead.

    There is only one usage of it in core (\Drupal\Tests\path_alias\Kernel\AliasTest) - by adding another usage and a factory for creating it, we're moving away from that todo.

    Are we sure we want to do that - can we achieve the same result with mocks?

    If not could we have our test implement ServiceModifierInterface and CacheFactoryInterface direct, to prevent others from inadvertently adding more usages of that class.

    Of if we're sure we want to keep that class around, should we be removing the todo here?

  3. +++ b/core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php
    @@ -82,6 +94,33 @@ public function testSource(array $source_data, array $expected_data, $expected_c
    +      $this->assertNotSame(FALSE, $cache_item, 'This is not a cache hit.');
    

    Should we use $this->assertNotEmpty instead of assertNotSame?

benjifisher’s picture

@larowlan:

#62.1: We discussed this question in #33-#35. I am neutral: I raised the same question. Both @mikelutz and @wimleers think it is a good idea to fix both at once.

#62.2: Maybe I just need more practice with mocks, but it seems to me that the tests are clearer if we use a real class in this case. I vote for removing the todo comment, but I will defer to people who have more experience with PHPUnit.

wim leers’s picture

Status: Needs review » Reviewed & tested by the community

#62:

  1. See #15 and #28 for more of the reasoning to merge these issues: it's very hard to do them separately, and much more complicated/risky.
  2. I don't think we should hold up a major performance boost on test refactoring 🤞
  3. No, we want to specifically assert FALSE because \Drupal\Core\Cache\CacheBackendInterface::get() returns FALSE when there is a cache miss 🤓

I did compare the key test coverage I had in #44 (in core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.php and
core/modules/migrate/tests/src/Kernel/Plugin/source/MigrateSqlSourceCountCacheTest.php) and it's still semantically identical (just slightly refactored). So I still feel confident that this fixes the original problem.


I think we have good answers for #62 and the patch still looks good to me, so moving back to RTBC.

Version: 9.2.x-dev » 9.3.x-dev

Drupal 9.2.0-alpha1 will be released the week of May 3, 2021, which means new developments and disruptive changes should now be targeted for the 9.3.x-dev branch. For more information see the Drupal core minor version schedule and the Allowed changes during the Drupal core release cycle.

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 60: 3190815-60.patch, failed testing. View results

catch’s picture

Status: Needs work » Reviewed & tested by the community

Restoring status after HEAD was broken.

benjifisher’s picture

Status: Reviewed & tested by the community » Needs work

The testbot reports a spelling error. Back to NW.

nickdickinsonwilde’s picture

Status: Needs work » Needs review
StatusFileSize
new17.53 KB

Any reason this isn't using a MR?
Regardless... attached for review.

nickdickinsonwilde’s picture

StatusFileSize
new590 bytes

Interdiff

benjifisher’s picture

Status: Needs review » Reviewed & tested by the community

Thanks for the quick fix! I verified that the only change is a spelling correction in a comment, and the testbot is happy. Back to RTBC.

I notice #3212521: [backport] cspell dislikes identifer in core/modules/views/src/Plugin/views/filter/FilterPluginBase.php and will fail any patch touching that file. Are there going to be a lot of spelling corrections on open issues from now on?

quietone’s picture

No, I don't think so. The spelling fix applied in #69 is a duplicate of #3212547: cspell Dictionaries changed, checking all files, which I reckon will be committed before this issue. To catch future changes to the dictionary a follow up was made, #3212579: Spell check all files if dictionary.txt changes.

wim leers’s picture

Status: Reviewed & tested by the community » Needs work

The last submitted patch, 69: 3190815-69.patch, failed testing. View results

quietone’s picture

ARF - another random failure, queued a retest

benjifisher’s picture

Status: Needs work » Reviewed & tested by the community

The retest passed. Back to RTBC.

alexpott’s picture

Version: 9.3.x-dev » 9.1.x-dev
Status: Reviewed & tested by the community » Fixed

Committed and pushed a6124cc0b6 to 9.3.x and 70a9d088d1 to 9.2.x and a6bb85217a to 9.1.x. Thanks!

  • alexpott committed a6124cc on 9.3.x
    Issue #3190815 by Wim Leers, quietone, NickDickinsonWilde, benjifisher,...

  • alexpott committed 70a9d08 on 9.2.x
    Issue #3190815 by Wim Leers, quietone, NickDickinsonWilde, benjifisher,...

  • alexpott committed a6bb852 on 9.1.x
    Issue #3190815 by Wim Leers, quietone, NickDickinsonWilde, benjifisher,...
wim leers’s picture

Yay — unpostponed all blocked issues in contrib, as well as the core issue, which I also just rerolled: #3190818-5: Allow source counts to be cached: implement ::doCount() instead of ::count().

Status: Fixed » Closed (fixed)

Automatically closed - issue fixed for 2 weeks with no activity.