Repository navigation
Add lazy-load guard to detect N+1 relation queries - #591
Conversation
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configuration
You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## master #591 +/- ##
============================================
+ Coverage 99.69% 99.70% +0.01%
- Complexity 662 686 +24
============================================
Files 43 47 +4
Lines 1622 1693 +71
============================================
+ Hits 1617 1688 +71
Misses 5 5 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
Tigrov
left a comment
There was a problem hiding this comment.
Two questions:
- Is this only for development or debugging / profiling?
- Why is
LazyLoadGuardnot designed as a class attribute#[Attribute(Attribute::TARGET_CLASS)]? It will not require properties$onlyand$exceptif apply the attribute to a specified AR class.
|
Tigrov
left a comment
There was a problem hiding this comment.
The solution looks a bit complicated. Perhaps better to move it to a separate trait where the counter will be enabled inside the method retrieveRelation() without using events. Then it will allow to use only this trait without difficult configurations.
|
Reworked into a separate |
findByPk(1)->getOrders()
findByPk(2)->getOrders()Would that trigger false positive? |
|
Docs mention "every lazy load" but it looks like it's 2nd lazy load now. |
| /** | ||
| * Sets the mode of the guard and the logger used in the {@see LazyLoadGuardMode::Log} mode. | ||
| */ | ||
| public static function set(LazyLoadGuardMode $mode, ?LoggerInterface $logger = null): void |
There was a problem hiding this comment.
Logger isn't used in strict mode.
| if (!isset(self::$dispatchers[$targetClass])) { | ||
| self::$dispatchers[$targetClass] = new Dispatcher(new Provider(self::getListenersFromAttributes($targetClass))); | ||
| } | ||
| self::$dispatchers[$targetClass] ??= new Dispatcher(new Provider(self::getListenersFromAttributes($targetClass))); |
|
Yes. Fixed in 4a715b6 — counted per result set now, grouped in foreach (Customer::query()->all() as $c) { $c->getOrders(); } // reported
Customer::query()->findByPk(1)->getOrders(); // not reported
Customer::query()->findByPk(2)->getOrders();Docs wording fixed in b12bf63 — |
|
|
|
|
Added in 8e97cf1. |
| */ | ||
| public static function reset(): void | ||
| { | ||
| self::$mode = LazyLoadGuardMode::Log; |
There was a problem hiding this comment.
Docs suggest calling reset() after each request in long-running workers, but it drops the mode and the logger too, so they have to be configured again every time. I'd either make reset() clear counters only, or add a separate method for that.
| use Yiisoft\Test\Support\EventDispatcher\SimpleEventDispatcher; | ||
| use Yiisoft\Test\Support\Log\SimpleLogger; | ||
|
|
||
| abstract class LazyLoadGuardTest extends TestCase |
There was a problem hiding this comment.
Missing cases:
- A record re-loading a relation after
refresh()/resetRelation(). asArray()result sets.- Records eager loaded with
with()that then lazy load a nested relation, e.g.Customer::query()->with('orders')->all()and then$order->getItems()for each order.
…rectly A record re-reading a relation after refresh() or resetRelation() was counted as another record of the result set, and the per-result-set counters were never freed. Both are gone with the counts held by a result set object in the WeakMap. class_uses() only returns the traits a class uses directly, so a model using the guard trait through a trait of its own was never guarded, and only the first model of a result set was checked. reset() dropped the mode and the logger too, which made it unusable between the requests of a worker; resetCounters() keeps them.
The suggested with() call named Customer whatever the model was, and offered a mode the enum doesn't have.
A WeakMap held as the value of a WeakMap entry keyed by the same record segfaults PHP when the records are freed. The result set only outlives its own records, so their ids are enough to count them.
|
| if (!$model instanceof ActiveRecordInterface || !self::isGuarded($model::class)) { | ||
| continue; | ||
| } |
There was a problem hiding this comment.
Can it be checked one time out of the loop?
There was a problem hiding this comment.
I'd like to, but that's the version @samdark asked me to change: it checked only the first model, and a set isn't guaranteed to be of one class, since createModels() returns whatever a resultCallback hands back. So it's one cached lookup per row now. If you both read a result set as always homogeneous, I'll happily move it out of the loop.
There was a problem hiding this comment.
I don't think it can be moved out of the loop without false positives.
| $resultSet = $resultSets[$model] ?? null; | ||
|
|
||
| if ($resultSet === null) { | ||
| /** A record populated outside a result set is a result set of its own. */ | ||
| $resultSet = new LazyLoadResultSet(); | ||
| $resultSets[$model] = $resultSet; | ||
| } |
There was a problem hiding this comment.
| $resultSet = $resultSets[$model] ?? null; | |
| if ($resultSet === null) { | |
| /** A record populated outside a result set is a result set of its own. */ | |
| $resultSet = new LazyLoadResultSet(); | |
| $resultSets[$model] = $resultSet; | |
| } | |
| $resultSets[$model] ??= new LazyLoadResultSet(); | |
| $resultSet = $resultSets[$model]; |
There was a problem hiding this comment.
Psalm at level 1 rejects it: PossiblyNullArgument on WeakMap::offsetSet() and PossiblyNullReference on the registerLazyLoad() call.
There was a problem hiding this comment.
Static analysis should not compromise code quality by necessitating increased complexity.
Either needs to find the appropriate annotations for static analysis or suspend it.
| $resultSets = self::$resultSets ?? new WeakMap(); | ||
|
|
||
| return self::$resultSets = $resultSets; |
There was a problem hiding this comment.
| $resultSets = self::$resultSets ?? new WeakMap(); | |
| return self::$resultSets = $resultSets; | |
| return self::$resultSets ??= new WeakMap(); |
There was a problem hiding this comment.
Psalm at level 1 infers new WeakMap() as WeakMap<object, mixed>, so it reports PropertyTypeCoercion, LessSpecificReturnStatement and MoreSpecificReturnType for it.
There was a problem hiding this comment.
Static analysis should not compromise code quality by necessitating increased complexity.
Either needs to find the appropriate annotations for static analysis or suspend it.
There was a problem hiding this comment.
The inline @psalm-var is exactly the "appropriate annotation" option — no suppression needed, and the one-liner fails Psalm level 1 with PropertyTypeCoercion / LessSpecificReturnStatement / MoreSpecificReturnType, since new WeakMap() infers as WeakMap<object, mixed>. Keeping it as is unless you prefer a @psalm-suppress instead.
| continue; | ||
| } | ||
|
|
||
| $resultSet ??= new LazyLoadResultSet(); |
| if (count($models) > 1) { | ||
| LazyLoadGuard::registerResultSet($models); | ||
| } |
There was a problem hiding this comment.
It is concerning that this code will always execute, even when N+1 query detection is not needed.
Why is it needed if you always create new LazyLoadResultSet() there https://github.com/yiisoft/active-record/pull/591/changes#diff-d6bdeea9079931e23a1a13d2c4a1974b57a13a8f0ec17e84fea80aa6c33be4dfR93
There was a problem hiding this comment.
Fair point, I measured it to be sure: registering 10,000 unguarded records takes 0.93 ms against 45.71 ms of hydrating them, so it's ~2% in the worst case I could build. If that's still too much, the cheapest fix is an explicit switch checked first thing here. @samdark had me drop the Off mode in 174a71d, so I'd rather not bring one back on my own — what do you two prefer, always on and cheap, or a switch again?
There was a problem hiding this comment.
But that's not consuming anything if not registered, right? So not an issue in production and very little resource consumption in dev/staging. That's acceptable to me.
There was a problem hiding this comment.
registering 10,000 unguarded records takes 0.93 ms against 45.71 ms
For debugging / profiling it is okay
Given this https://github.com/yiisoft/active-record/pull/591/changes#r4244295174, it's better to remove this in production.
| } | ||
|
|
||
| $resultSet ??= new LazyLoadResultSet(); | ||
| self::resultSets()[$model] = $resultSet; |
There was a problem hiding this comment.
Call of self::resultSets() also possible to move outside:
For example self::$resultSets ??= new WeakMap();
|
|
@Tigrov Both |
Closes #590
What —
LazyLoadGuardTraitoverridesretrieveRelation()and reports every lazy load toLazyLoadGuard, which hasLog(default) /Strictmodes and is configured statically:LazyLoadGuard::set(LazyLoadGuardMode::Strict)orLazyLoadGuard::set(LazyLoadGuardMode::Log, $logger). No events and no dispatcher configuration; models without the trait are not affected. Meant for development and testing.How —
retrieveRelation()is the single lazy-load path;with()populates$relatedup front sorelation()returns before reaching it. Works together withEventsTrait, which doesn't overrideretrieveRelation().psr/logadded to require-dev + suggest and registered incomposer-dependency-analyser.phpalongside the other opt-in packages.Tests —
tests/LazyLoadGuardTest.php, 9 cases across all five drivers: counting lazy loads, not counting afterwith(), each mode, combination withEventsTrait, andreset().Sqlite suite green (467 tests, 2022 assertions). Psalm, Rector, PHP CS Fixer and composer-dependency-analyser clean.