cockroachdb / cockroachdb/cockroach
sql/catalog: ResolveFunction has no per-statement cache; redundant search_path walks during planning
- Dominant language
- Go
- Stars
- 32.5k
- Forks
- 4.1k
- PR merge metrics
- PR metrics pending
Description
### Describe the problem
`schemaResolver.ResolveFunction` ([`pkg/sql/schema_resolver.go:481`](https://github.com/cockroachdb/cockroach/blob/master/pkg/sql/schema_resolver.go#L481)) has no per-statement memoization. Every call walks the entire session search_path via `maybeLookupRoutine`, performing `LookupSchema(db, scName)` for each path entry — even when the same name was just resolved a moment earlier in the same statement.
Each `LookupSchema` does at least two descriptor-cache accesses (Database + Schema). For code that resolves the same function name N times within a single planning cycle, the planner pays `N × 2 × len(search_path)` descriptor cache accesses, even though the result is deterministic for the same `(name, search_path)` within the statement.
For **built-in functions** specifically — which dominate the planner's resolution traffic — the work is doubly wasteful: the answer is always the same regardless of search_path entries (since `pg_catalog` is implicitly always available and the built-in lives there), yet the per-path-entry walk still fires.
### Why the existing caching layers don't cover this
CRDB has several caches in this area; none plug this gap.
| Layer | Covers | Why it doesn't help |
|---|---|---|
| `descs.Collection` | descriptor lookups by ID | Descriptor cache hits cleanly. The problem is the layer above calls into it repeatedly. |
| Lease manager | leased descriptor versions | Feeds `descs.Collection`; same story. |
| Query plan cache | optimized plans by query digest | Doesn't help when queries have unique literals (custom plan type). |
| `resolvedTypesByOID` | hydrated UDTs by OID, per-statement | Only covers type-by-OID resolution, not function-by-name. |
| `pgClassOIDCache` | `pg_class` OID → name/schema, per-statement | Different concern. |
The missing piece is a per-statement memoization of `(function name, search_path) → ResolvedFunctionDefinition`, directly analogous to what was added for types in `resolvedTypesByOID`.
### When it bites
Most queries resolve each function name a handful of times — the overhead is invisible. But certain planner paths can hit `ResolveFunction` thousands of times for a single statement (e.g. constant-folding the same built-in expression over many input values, or any path that constructs many similar scalar expressions during exploration). In those cases the redundant work can dominate planning latency.
### Suggested fix
Add a per-statement function-resolution cache on `schemaResolver`, mirroring `resolvedTypesByOID`:
- New field: `resolvedFunctionsByName map[string]cachedFunction`, where `cachedFunction` holds the resolved definition plus the lease generation captured at insert time
- Cache use gated on `!sr.skipDescriptorCache && sr.typeResolutionDbID == descpb.InvalidID` (same gate as the UDT cache; respects `runWithOptions` callers that require fresh reads)
- Validate entries against `descs.Collection.GetLeaseGeneration()` on lookup, evicting on mismatch so in-statement DDL on functions is reflected correctly
- Cleared per statement in `planner.resetPlanner`
Estimated change: ~30 lines, structurally identical to the existing `resolvedTypesByOID` pattern. The same `cachedFunction`/lease-gen pattern is worth retrofitting onto the existing `resolvedTypesByOID` cache (which has the same in-statement-DDL gap) and onto `pgClassOIDCache` for defense in depth.
### Related
- #171543 — uncached negative namespace lookups (different mechanism, same family of "expensive name resolution during planning")
Jira issue: CRDB-65266
Contributor guide
Assessment
This issue has not been assessed yet.