Skip to content

Fix #4586: parser cache collides on closed generics#4587

Merged
jeremydmiller merged 1 commit into
masterfrom
fix/4586-contains-parser-cache-per-T
May 29, 2026
Merged

Fix #4586: parser cache collides on closed generics#4587
jeremydmiller merged 1 commit into
masterfrom
fix/4586-contains-parser-cache-per-T

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Closes #4586.

Bug

LinqParsing.FindMethodParser cached the result of resolving a custom IMethodCallParser by (Module, MetadataToken). For generic methods like Enumerable.Contains<T>, the MetadataToken is the same regardless of the closed-over T — the token points to the generic definition in the metadata table, not the reified closed method.

Consequence: the first parser that matched any T was returned for every subsequent T on the same generic method, even when the new T didn't satisfy the parser's Matches() guard. From the issue's repro:

options.Linq.MethodCallParsers.Insert(0, new StrongIdContainsParser());
// Matches only Enumerable.Contains<StrongId>

await session.Query<Doc>()
    .Where(doc => Enumerable.Contains(strongIds, doc.ExternalId))
    .ToListAsync();
// ^ Hits StrongIdContainsParser as designed.

await session.Query<Doc>()
    .Where(doc => Enumerable.Contains(names, doc.Name))
    .ToListAsync();
// ^ BUG: also hits StrongIdContainsParser because the cache slot for
//   (Enumerable module, Contains<T> metadata token) was already filled.

Fix

Key the cache by MethodInfo. MethodInfo.Equals / GetHashCode on the closed reified method include the generic arguments, so each closed generic gets its own cache slot:

-private ImHashMap<Module, ImHashMap<int, IMethodCallParser>> _methodParsersByModule = ...;
+private ImHashMap<MethodInfo, IMethodCallParser> _methodParsers = ...;

FindMethodParser simplifies to a single map lookup + AddOrUpdate. Drops the two-level (Module → int → parser) map from #4374 — that nesting only existed to keep the inner int slot small, and the int slot was the collision source.

Test

LinqTests/Bugs/Bug_4586_parser_cache_collides_on_closed_generics.cs mirrors the issue's repro:

  1. Insert a custom Enumerable.Contains parser gated on T == Bug4586StrongId at position 0.
  2. Run a query that should hit it — assert a controlled throw + HitCount == 1.
  3. Run a second query with Enumerable.Contains<string> — must NOT hit the custom parser; assert HitCount stays at 1 + the query round-trips normally.

Regression check

custom_linq_extensions.query_with_custom_parser (the existing custom-parser path) + 44 other Contains / IsOneOf / IsNotOneOf tests still pass locally.

🤖 Generated with Claude Code

LinqParsing.FindMethodParser cached the result of resolving a custom
IMethodCallParser by (Module, MetadataToken). For generic methods like
Enumerable.Contains<T> the MetadataToken is the SAME regardless of the
closed-over T — the token points to the generic *definition* in the
metadata table, not the reified closed method. Consequence: the first
parser that matched any T was returned for every subsequent T on the
same generic method, even when the new T didn't satisfy the parser's
Matches() guard.

Concrete repro from the issue: a custom parser gated on
expression.Method.GetGenericArguments()[0] == typeof(StrongId)
matched Enumerable.Contains<StrongId> as designed — but a later
Enumerable.Contains<string> query then hit the *same* cached
StrongId-only parser instead of falling through to the standard
handling.

Fix: key the cache by MethodInfo. MethodInfo.Equals / GetHashCode on
the closed reified method include the generic arguments, so each
closed generic gets its own cache slot. Simplifies the cache to a
single ImHashMap<MethodInfo, IMethodCallParser> — drops the
(Module → int → parser) two-level map from the #4374 optimization
since the per-module keying was only there to keep the int slot
small and the int slot was the collision source.

Test: LinqTests/Bugs/Bug_4586_parser_cache_collides_on_closed_generics
mirrors the issue's repro. Inserts a custom Enumerable.Contains
parser gated on T == Bug4586StrongId at position 0, runs a query
that should hit it (asserts a controlled throw), then runs a second
query with Enumerable.Contains<string> that must NOT hit the custom
parser. Both before/after assertions on the parser's HitCount pin
the contract.

Regression sweep: custom_linq_extensions.query_with_custom_parser
+ 44 other Contains / IsOneOf / IsNotOneOf tests still pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Parser selection for generic Enumerable.Contains<T> is cached across different T

1 participant