Skip to content

Commit e722043

Browse files
committed
Faster Eloquent scope-method resolution
1 parent d209b97 commit e722043

6 files changed

Lines changed: 59 additions & 29 deletions

File tree

docs/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5959
- **Faster diagnostics on method/function calls that resolve to no concrete class.** Checking whether such a call's result was actually a bare `object`/`?object` (still valid for member access) used to re-resolve the callee's whole receiver chain and method signature a second time from scratch. That check now reuses the resolution already performed, roughly halving diagnostics time on files with many unresolved or missing-method call chains.
6060
- **Argument checking no longer slows down quadratically with file size.** The argument-count and argument-type checks know the byte offset of every call they inspect, but used to convert it into an editor line/column position and immediately back again before looking up the called function. Each of those conversions re-read the file from the beginning to count characters, so the cost of checking one call grew with the size of the file around it and a file with thousands of calls spent nearly all its time on offset arithmetic. The offset is now used directly. On a 370 KB file containing 2200 calls the two checks together drop from 13.7 seconds to 0.2, taking the whole file from 16.7 seconds to 3.4, and analysing the project it belongs to is close to three times faster.
6161
- **Faster workspace symbol search.** Matching a symbol against the "Go to Symbol in Workspace" query no longer allocates a lowercased copy of every class, method, property, constant, and function name in the project on each keystroke; matching is now done byte-wise in place for the common case of ASCII identifiers.
62+
- **Faster Eloquent scope-method resolution.** Injecting a model's scope methods onto its Builder used to re-walk the model's full inheritance chain (traits and parent classes) from scratch on every `Builder<Model>` instantiation. That base resolution is now cached, so a file with many instantiations of the same model's Builder resolves its scopes once instead of repeatedly.
6263

6364
### Removed
6465

docs/todo.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -200,7 +200,6 @@ unlikely to move the needle for most users.
200200
| P47 | [The resolved-class cache lock caps concurrent class resolution](todo/performance.md#p47-the-resolved-class-cache-lock-caps-concurrent-class-resolution) | Medium | Medium-High |
201201
| P16 | [Pre-parsed stub format (eliminate raw PHP embedding)](todo/performance.md#p16-pre-parsed-stub-format-eliminate-raw-php-embedding) | High | Medium-High |
202202
| P22 | [Signature change re-queues slow diagnostics for every open file](todo/performance.md#p22-signature-change-re-queues-slow-diagnostics-for-every-open-file) | Medium-High | Medium |
203-
| P11 | [Uncached base-resolution in `build_scope_methods_for_builder`](todo/performance.md#p11-uncached-base-resolution-in-build_scope_methods_for_builder) | Low-Medium | Low |
204203
| P3 | Parallel pre-filter in `find_implementors` | Low-Medium | Medium |
205204
| P6 | O(n²) transitive eviction in `evict_fqn` | Low | Low |
206205
| P15 | [Two-phase stub index construction (eliminate `RwLock` on stub maps)](todo/performance.md#p15-two-phase-stub-index-construction-eliminate-rwlock-on-stub-maps) | Low | Medium |

docs/todo/performance.md

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -56,32 +56,6 @@ threshold (e.g. 8 files).
5656

5757
---
5858

59-
## P11. Uncached base-resolution in `build_scope_methods_for_builder`
60-
61-
**Impact: Low-Medium · Effort: Low**
62-
63-
`build_scope_methods_for_builder` calls
64-
`resolve_class_with_inheritance` (base resolution) for the model
65-
class. This is not covered by the thread-local resolved-class
66-
cache, which stores fully-resolved classes (after virtual member
67-
injection), not base-resolved ones.
68-
69-
Every time an Eloquent `Builder<Model>` is resolved with scope
70-
injection, the model is base-resolved from scratch. With many
71-
Builder instantiations in a single file this adds up.
72-
73-
### Fix
74-
75-
Either introduce a separate base-resolution cache (keyed by FQN),
76-
or restructure so `build_scope_methods_for_builder` accepts the
77-
already-resolved model class from the caller (which may already
78-
have it from the resolved-class cache). The caller
79-
(`inject_scopes_and_model_methods`) already has the resolved-class
80-
cache in scope — it passes it to `inject_model_virtual_methods`
81-
but not to this function.
82-
83-
---
84-
8559
## P15. Two-phase stub index construction (eliminate `RwLock` on stub maps)
8660

8761
**Impact: Low · Effort: Medium**

src/virtual_members/laravel/scopes.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,10 @@ pub fn build_scope_methods_for_builder(
162162
// #[Scope] methods into their public-facing form (replacing the
163163
// original), which makes them invisible to `is_scope_method`.
164164
// Using the pre-provider resolution preserves the raw methods.
165+
// Cached so that resolving many Builder instantiations of the same
166+
// model in one file doesn't re-walk its inheritance chain each time.
165167
let resolved_model =
166-
crate::inheritance::resolve_class_with_inheritance(&model_class, class_loader);
168+
crate::virtual_members::resolve_class_base_cached(&model_class, class_loader);
167169
// Build a substitution map so that `static`, `$this`, and `self`
168170
// in scope return types resolve to the concrete model name.
169171
// The default scope return type is `\...\Builder<static>` where

src/virtual_members/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ pub use cache::{
6060
pub(crate) use cache::{
6161
TransformFingerprint, intern_transformed_method, intern_transformed_property,
6262
};
63+
pub(crate) use resolve::resolve_class_base_cached;
6364
pub use resolve::{
6465
populate_from_sorted, resolve_class_fully, resolve_class_fully_cached,
6566
resolve_class_fully_maybe_cached, resolve_class_fully_with_generics,

src/virtual_members/resolve.rs

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ use crate::util::short_name;
2727
use crate::virtual_members::laravel::patches::apply_laravel_patches;
2828

2929
use super::laravel;
30-
use super::{ResolvedClassCache, ResolvedClassCacheKey, apply_virtual_members, default_providers};
30+
use super::{
31+
ResolvedClassCache, ResolvedClassCacheKey, active_resolved_class_cache, apply_virtual_members,
32+
default_providers,
33+
};
3134

3235
// ─── Cycle breaking ─────────────────────────────────────────────────────────
3336
//
@@ -252,6 +255,56 @@ pub fn resolve_class_fully_maybe_cached(
252255
result
253256
}
254257

258+
/// Reserved generic-args marker for [`resolve_class_base_cached`]'s cache
259+
/// key. Contains a NUL byte, which `PhpType::to_string()` never produces,
260+
/// so it can never collide with a real `(FQN, generic_args)` key.
261+
const BASE_RESOLUTION_MARKER: &str = "\0base";
262+
263+
/// Resolve a class's base inheritance only (own members + traits +
264+
/// parent chain), skipping the walk on repeat lookups of the same class
265+
/// via the active [`ResolvedClassCache`].
266+
///
267+
/// This is the *pre-virtual-member-provider* stage of
268+
/// [`resolve_class_fully_inner`]: unlike [`resolve_class_fully`], it does
269+
/// not run virtual member providers, so scope methods (`scopeX` /
270+
/// `#[Scope]`) are still present in their raw, undecorated form — the
271+
/// Laravel provider replaces them with their public-facing scope method,
272+
/// which would make them invisible to callers that need to recognize the
273+
/// original scope declaration (e.g. Eloquent scope-method synthesis).
274+
///
275+
/// Stored under the same key space as the fully-resolved cache
276+
/// (`(FQN, generic_args)`), tagged with [`BASE_RESOLUTION_MARKER`] so it
277+
/// can never collide with a real generic instantiation. Sharing the key
278+
/// space means base-resolution entries ride along with the fully-resolved
279+
/// cache's existing FQN-keyed eviction (`evict_fqn`/`remove_all_variants`),
280+
/// which already removes every generic-arg variant of an invalidated FQN
281+
/// transitively through `reverse_deps` — a separate cache would have to
282+
/// duplicate that invalidation graph to stay correct.
283+
///
284+
/// Falls back to an uncached call to [`resolve_class_with_inheritance`]
285+
/// when no cache is active on this thread.
286+
pub(crate) fn resolve_class_base_cached(
287+
class: &ClassInfo,
288+
class_loader: &dyn Fn(&str) -> Option<Arc<ClassInfo>>,
289+
) -> Arc<ClassInfo> {
290+
let Some(cache) = active_resolved_class_cache() else {
291+
return Arc::new(resolve_class_with_inheritance(class, class_loader));
292+
};
293+
294+
let key: ResolvedClassCacheKey = (class.fqn(), vec![BASE_RESOLUTION_MARKER.to_string()]);
295+
296+
{
297+
let map = cache.read();
298+
if let Some(cached) = map.get(&key) {
299+
return Arc::clone(cached);
300+
}
301+
}
302+
303+
let result = Arc::new(resolve_class_with_inheritance(class, class_loader));
304+
cache.write().insert(key, Arc::clone(&result));
305+
result
306+
}
307+
255308
/// Resolve a class fully and apply generic type argument substitution,
256309
/// caching the combined result under `(FQN, generic_arg_strings)`.
257310
///

0 commit comments

Comments
 (0)