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
- in core: #3190818: Allow source counts to be cached: implement ::doCount() instead of ::count()
- in contrib: metatag: #3190804: Allow source counts to be cached: implement ::doCount() instead of ::count()
- in contrib: media_migration: #3190800: Allow source counts to be cached: implement ::doCount() instead of ::count()
- in contrib: pathauto: #3190980: Allow source counts to be cached: implement ::doCount() instead of ::count()
- in contrib: redirect: #3162128-6: Derive path redirect migrations per entity type
- in contrib: location_migration: #3192657: EntityLocationFieldInstance & LocationFieldStorage do not count source items correctly
- in contrib: bean_migrate: #3199294: Remove source count caching workaround when core issue is resolved
Steps to reproduce
Proposed resolution
- Update
SqlBaseto 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. - Update
SourcePluginBase::count()to not read from or write to the cache whencache_counts != truein the migration definition. - 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.
- Include the changes from #3092227: SourcePluginBase should cache based on a hash of the source configuration, not just the plugin id..
- 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
| Comment | File | Size | Author |
|---|---|---|---|
| #70 | interdiff-64-69.txt | 590 bytes | nickdickinsonwilde |
| #69 | 3190815-69.patch | 17.53 KB | nickdickinsonwilde |
| #60 | 3190815-60.patch | 17.17 KB | quietone |
| #60 | interdiff-56-60.txt | 702 bytes | quietone |
| #55 | interdiff-48-55.txt | 4.99 KB | quietone |
Comments
Comment #2
wim leersComment #3
wim leersAnd 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:
That's not how
SourcePluginBasebehaves today.So it's no wonder that nobody ever complained about this: it's very broken today, alas.
Comment #4
wim leersNot sure what happened there. Rebased.
Comment #5
wim leersComment #6
wim leersShould be
protected.I think the fact that #4 is green proves there is no BC break, especially considering I even accidentally forgot to change
publictoprotected! 🙈Comment #7
wim leersComment #8
benjifisherWhy is this issue marked Major?
This override needs a doc block explaining what it actually does:
Update the comment:
It may be out of scope for this issue, but I would like to clean this up:
We can avoid a couple of lines of duplicated code:
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.
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.
Comment #9
wim leersComment #10
wim leers#8:
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.
Comment #11
wim leersAddressed #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.
Comment #12
wim leersComment #13
benjifisher8.1: Thanks for the explanation.
8.2: Currently, the API docs for SqlBase::count() say,
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,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
elseafterif (...) { return ... }. That is the usual style in Drupal, even though many good programmers hate it.Back to NW for 8.2 and 8.6.
Comment #14
benjifisherComment #15
wim leersI 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: truein your migration definition, then none of this kicks in anyway. If this patch lands, you'll need to specifycache_keytoo 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).Comment #16
wim leersThis is not gonna work in D9. #15 will fail because of this.
This should be
protected.This does not make sense.
We're not specifying
cache_countsin 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.Comment #17
wim leersIn #16, this is the only case that is tested. Let's test all other cases too.
Comment #18
wim leersWell THAT is unfortunate, #16 😬Tests did not run due to a simple pre-existing word in a test-only module 🤯🤦♂️ This is rather counterproductive.
Comment #20
wim leers#13.8.6: in progress 🤓
#13.8.2: solved in #15, by bringing over @mikelutz' work 🤓
Comment #21
wim leersComment #22
wim leersMade your suggested change.
Plus some clarifications.
Comment #23
wim leersComment #24
wim leersAhhh … the classic "cross-posting causes the node to get unpublished" bug on d.o 😬
Comment #25
mikelutzThe 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.
Comment #26
wim leers#21 and #22 are passing
MigrateSqlSourceCountCacheTest, so time to upload a test-only patch.FYI …
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
and
It'd actually be trivial to implement in this issue 🤓
EDIT: did that in #28!
Comment #27
wim leers#25:
Ahhh, of course! 😬
Thanks so much for chiming in with that, it's obvious in hindsight! 🙈
Comment #28
wim leersSo … 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! 🤓🙏
Comment #31
mikelutzLGTM. 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.
Comment #32
wim leersIndeed! 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!
Comment #33
benjifisher@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.
What are the guidelines for adding a word to the dictionary instead of skipping spell check on a particular line?
The spell checker complains about
migrate_sqlbase_count_cache_test(the module name) in thenamespaceline? That seems like a problem … a problem for another day.+1 for moving this to the constructor:
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 applyingJson::encode()? That is,Json::encode(['plugin_id' => $plugin_id] + $configuration).I still think we should update the doc block (#8.2). See #13.
Nit: it is valid YAML, but the usual practice is to indent the dependency list:
Also, the description should have ending punctuation but not the name.
It seems like a cheat to use
source_module = "migrate", but I guess it is good enough for the test.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?Do we want to say that this test
@coversthe test class, notSqlBaseorSqlBase::count()?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?This looks like a problem:
That is not the right cache key! If we keep the reflection hack, then we could add a
protectedmethod 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 anelseifclause afterif ($expected_cache_key)that checks for$source_plugin_configuration['cache_counts'], and add a corresponding entry to the data provider.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_resultsif we never reference it?Comment #34
mikelutzWe 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.
Comment #35
wim leers#34:
Amen.
#33:
.info.ymland the class — see the #15 test results. There's loads of classnames indictionary.txtalready, for examplebrowsertestbase. Furthermore, this also matches the guidelines in https://www.drupal.org/node/3122084:\Drupal\migrate_high_water_test\Plugin\migrate\source\HighWaterTest::fields().::count()and a cache hit afterwards.\Drupal\Tests\migrate\Kernel\MigrateSqlSourceTestBase, which forces that structure upon us. The entire data provider structure is dictated by this. Only that fifthexpected_cache_idparameter is "custom/extra" here compared to the test base class.Comment #37
benjifisherIn #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
dataproperty 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.This is much better. Now that
$cacheKeyis set in the constructor (#33.2), we can test it here.After asserting
$expected_count == $count, we could assert$expected_count == $cached_countinstead of asserting that two computed values are equal:Same snippet: you removed the
elseclause, 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?Follow-up to #33.10 and the reply in #35. In
MigrateSourceTestBase, we declare thatproviderSource()is the data provider fortestSource(). 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@covercomments for everything tested bytestSource(). Maybe the right way to do that would be to declaretestSource(), 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.Comment #38
benjifisherRe #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_idhave been created. That seems like a lot of work just to verify that something has not been cached.Comment #39
quietone commentedThis is blocking #3190818: Allow source counts to be cached: implement ::doCount() instead of ::count()
Comment #40
quietone commentedThis is not specific to Drupal 7 sources.
Comment #41
wim leersNow also patches for
location_migrationat #3192657: EntityLocationFieldInstance & LocationFieldStorage do not count source items correctly andbean_migrateat #3199294: Remove source count caching workaround when core issue is resolved.Comment #42
quietone commented37.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).
Comment #43
wim leers#42: looks good — just two nits:
Cache ID vs key. This is not consistent right anymore now.
Indentation is off here and in all subsequent cases.
Fixed those nits in this reroll.
Comment #44
wim leers#37:
Addressed in this reroll.
Now the test-only fix is meaningful and should show 3 failures (3 out of 4 test cases should fail).
Comment #47
wim leers@benjifisher to your point, #43's test-only patch fails like this:
whereas #44's fails like this:
So I believe your concerns have been addressed. I believe this is ready to be RTBC'd.
Comment #48
quietone commented@Wim Leers, glad you liked the changes in #42.
I missed this.
The parameter name in the method signature is expected_cache_key.
Comment #49
wim leersHey, I missed that too! 😅
Comment #50
wim leersAnything left here for this to land? 🤞😊
Comment #51
benjifisherIt 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.
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.
Comment #52
benjifisherI 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.
This is more complicated than it has to be:
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 calldoCount()and return its value. If$this->cacheCountsis T, then we cache the computed value before returning it. Like this: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.
Can we use
string|nullfor the@paramcomment?There are a lot of calls to
\Drupal::cache('migrate')here:Can we call it just once, before the
ifblock:$cache = \Drupal::cache('migrate')? Or call it$migrate_cacheif you think that is clearer. If we add an@varcomment, that will help future us understand wheregetCounter()comes from.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?Is that someone’s birthday? If so, you are 30 days older than my daughter. Maybe I can introduce you some day. ;)
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?
Comment #53
quietone commentedI'll look at this in the next few days
Comment #54
quietone commented#52.1 Updated the IS.
Comment #55
quietone commented#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.
Comment #56
quietone commentedYes, 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?
Comment #57
quietone commentedOK, this is ready for more review.
Comment #58
benjifisherI am reviewing the patch in #56. For now, I will update the issue summary following #51.
@quietone:
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.
Comment #59
benjifisher@quietone:
Thanks for all the updates based on #52. They look good, except for one minor point:
In #52.4, I said, "If we add an
@varcomment, that will help future us understand wheregetCounter()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.Comment #60
quietone commentedAs 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.
Comment #61
benjifisher@quietone, thanks for the addition! Back to RTBC.
Comment #62
larowlanSome great work here folks, a few observations/questions
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?
Should we use $this->assertNotEmpty instead of assertNotSame?
Comment #63
benjifisher@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.
Comment #64
wim leers#62:
FALSEbecause\Drupal\Core\Cache\CacheBackendInterface::get()returnsFALSEwhen there is a cache miss 🤓I did compare the key test coverage I had in #44 (in
core/modules/migrate/tests/src/Kernel/MigrateSqlSourceTestBase.phpandcore/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.
Comment #67
catchRestoring status after HEAD was broken.
Comment #68
benjifisherThe testbot reports a spelling error. Back to NW.
Comment #69
nickdickinsonwildeAny reason this isn't using a MR?
Regardless... attached for review.
Comment #70
nickdickinsonwildeInterdiff
Comment #71
benjifisherThanks 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?
Comment #72
quietone commentedNo, 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.
Comment #73
wim leers#72++
Comment #75
quietone commentedARF - another random failure, queued a retest
Comment #76
benjifisherThe retest passed. Back to RTBC.
Comment #77
alexpottCommitted and pushed a6124cc0b6 to 9.3.x and 70a9d088d1 to 9.2.x and a6bb85217a to 9.1.x. Thanks!
Comment #81
wim leersYay — 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().