Skip to content

Pick up JasperFx 1.27.0 indexed type lookup + trim two SaveChanges allocations#4295

Merged
jeremydmiller merged 1 commit intomasterfrom
marten-cold-start-optimizations
Apr 27, 2026
Merged

Pick up JasperFx 1.27.0 indexed type lookup + trim two SaveChanges allocations#4295
jeremydmiller merged 1 commit intomasterfrom
marten-cold-start-optimizations

Conversation

@jeremydmiller
Copy link
Copy Markdown
Member

Summary

Non-breaking cold-start + per-SaveChanges allocation cleanup, carved out of the Marten 9.0 umbrella issue (#4294) so the simpler wins don't have to wait for 9.0.

What lands

  1. JasperFx package bumped 1.26.0 → 1.27.0, picking up JasperFx/jasperfx#192 (closes JasperFx/jasperfx#189). CodeGenerationExtensions.FindPreGeneratedType is now O(1) per call after the first lookup per Assembly, instead of O(ExportedTypes). Static-mode cold start (Marten with TypeLoadMode.Static and pre-generated code in the entry assembly) stops paying an assembly.ExportedTypes.FirstOrDefault scan per ICodeFile.AttachTypes call.

  2. QuickEventAppender: hoist the per-stream Queue<long> allocation out of the foreach over WorkTracker.Streams. The quick-append path never reads from it (only applyRichMetadata dequeues; the quick variant doesn't touch it), so a single throwaway instance is correct. Bulk-append workloads stop paying one allocation per stream.

  3. DocumentSessionBase.operationDocumentTypes(): replace Operations().Select(...).Where(...).Distinct() with a single-pass HashSet<Type> walk. Same observable behavior, fewer intermediate enumerator/list allocations on every SaveChanges.

What deliberately did not land

The bigger cold-start items in #4294 — lazy BuildAllMappings, lazy EventGraph.FeatureSchema.Objects, Static-mode validation skip, IEnumerable<StreamAction> widening — are still 9.0 candidates because they each have semantic-change risk or signature implications. Tracked there.

Test plan

  • Build clean (Marten + EventSourcingTests)
  • Targeted event-sourcing sweep (end_to_end_event_capture_and_fetching_the_stream, quick_append_event_capture_and_fetching_the_stream, archiving_events, start_stream_should_enforce_*) — 212 tests, all green
  • Full CoreTests sweep — 327 passed, 1 skipped (pre-existing), 0 failed

🤖 Generated with Claude Code

…ocations

JasperFx 1.27.0 (JasperFx/jasperfx#192) makes
CodeGenerationExtensions.FindPreGeneratedType O(1) per call by caching an
indexed dictionary per Assembly. Bumps the pin so static-mode cold start
stops paying O(ExportedTypes) per ICodeFile.AttachTypes call.

Two small Marten-side fixes alongside:

* QuickEventAppender previously allocated a new Queue<long> per stream
  inside the foreach over WorkTracker.Streams, even though the quick-append
  path never reads from it (only applyRichMetadata dequeues; applyQuick
  doesn't touch it). Hoist a single throwaway instance out of the loop so
  bulk appends stop paying one allocation per stream.

* DocumentSessionBase.operationDocumentTypes() did
  Operations().Select(...).Where(...).Distinct(), enumerating Operations()
  twice and chaining a fresh enumerator stack on every SaveChanges. Replace
  with a single-pass HashSet<Type> walk. Same observable behavior, fewer
  intermediate allocations on the hot save path.

Companion to the Marten 9.0 cold-start umbrella issue (#4294); the
non-breaking subset that didn't have to wait for 9.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit 36f307b into master Apr 27, 2026
6 of 7 checks passed
@jeremydmiller jeremydmiller deleted the marten-cold-start-optimizations branch April 27, 2026 22:35
jeremydmiller added a commit that referenced this pull request Apr 27, 2026
JasperFx.Events 1.30.0 (JasperFx/jasperfx#193) makes
ProjectionGraph.DiscoverGeneratedEvolvers cheaper at cold start by
filtering framework / library assemblies out before the
GetCustomAttributes scan and by caching results across IDocumentStore
instances in the same process. Bumps the pin so we benefit immediately.

Companion Marten-side optimization in the same PR: pre-populate
EventGraph._nameToType from registered event types during Initialize.
TypeForDotNetName otherwise falls through Type.GetType(assemblyQualifiedName)
on first read of each event type from the database, and that fallback is
itself O(loaded-assemblies). Pre-warming both the AssemblyQualifiedName and
FullName entries in a single ImHashMap.Swap means the first read of every
known event type lands directly in the cache. New types (those discovered
lazily via _events.OnMissing) still fall through the existing path.

Continues the 8.x cold-start trim begun in #4295. See umbrella issue #4294.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jeremydmiller added a commit that referenced this pull request Apr 28, 2026
…ze (#4302)

Round 3 of the 8.x cold-start trim begun in #4295/#4296. Two more lazy
caches in EventGraph that paid an O(n) linear walk on first lookup of
each name; pre-populate them at Initialize from already-known data so
the first request of every alias is a hit.

* _byEventName is keyed by EventTypeName and OnMissing was a
  AllEvents().FirstOrDefault(x => x.EventTypeName == name) walk per miss.
  Add Fill() for every registered mapping in the same loop that already
  walks _events. First lookup of each alias is now O(1).

* _aggregateTypeByName is keyed by aggregate alias and findAggregateType
  iterated Options.Projections.AllAggregateTypes() on miss. Walk that
  collection once at Initialize and Fill the entries up front. Same shape
  as the existing OnMissing logic, just moved off the request path.

Behavior unchanged: in both caches, the existing OnMissing/Fill paths
remain intact for any name that wasn't registered at Initialize time
(rare but possible -- e.g. types added via _events.OnMissing later).

Continues the 8.x cold-start trim. The remaining items are 9.0 scope and
each tracked separately under the Marten 9.0 milestone.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jeremydmiller added a commit that referenced this pull request Apr 28, 2026
Three independent root causes feed the test-flake pattern that's been
hitting recent PRs (#4279, #4281, #4292, #4295, #4296, #4302). All three
are localized; this PR addresses each.

## Root cause 1: shared static random sequence

Target.cs defines `private static readonly Random _random = new Random(67)`
that is consumed by ~80 tests across LinqTests + DocumentDbTests. Each
Target.Random() / GenerateRandomData() call advances the shared sequence,
so a test's effective random data depends on which sibling tests ran
before it. xUnit discovery order is mostly stable but NOT guaranteed
identical run-to-run, especially across CI workers with different load,
.NET TFM combinations, etc. A small order shift consumes a different
slice of the sequence and produces different test data — silently flipping
assertions that depend on exact counts or distributions.

Fix: introduce `Target.ResetRandomSeed(int seed = 67)` so a test that
genuinely depends on specific random data can pin the sequence at the
start. Remove the readonly modifier on _random to allow rebinding.

Updated tests to call ResetRandomSeed():
- Bug_605_unary_expressions_in_where_clause_of_compiled_query (3 facts)
- Bug_3337_select_page.try_it_out
- query_against_child_collections.buildUpTargetData (covers
  can_query_on_enum_properties and many more)

Also tightened Bug_605's assertion: it was hardcoded to `.ShouldBe(15)`
but the real point of the test is "compiled query == inline LINQ query for
the same expression"; the page size of 15 is incidental. Compare against
expected.Count instead so the test is robust to data variance.

## Root cause 2: DateTimeOffset.UtcNow inside a shared LINQ expression

`child_collection_queries.cs:67` was registering this where-clause for the
acceptance suite:

    @where(x => x.Children.Any(c => c.NullableDateOffset <= DateTimeOffset.UtcNow));

That expression runs in BOTH the in-memory LINQ-to-objects "expected"
provider AND the LINQ-to-SQL "actual" provider. Each provider evaluates
DateTimeOffset.UtcNow at its own moment. Target.NullableDateOffset values
are ±60 seconds of "now" from random data; values within microseconds of
either provider's "now" can land on opposite sides of <= and disagree.

Fix: capture a fixed `asOf = DateTimeOffset.UtcNow.AddDays(1)` in the
static ctor and use that as the boundary. The expression now embeds a
constant timestamp that both providers see identically. AddDays(1) puts
it well beyond the test data range so the predicate is meaningfully true
for matching rows.

## Root cause 3: ordering assumptions on server-generated Guids

Bug_4282 asserted `ids.ShouldHaveTheSameElementsAs(doc1.Id, doc3.Id)`
after `OrderBy(x => x.Id)`. The IDs are server-generated Guids; their
sort order does not in general match declaration order (Marten uses
sequential Guids in many configs but not always, depending on the
StoreOptions in scope and the underlying provider). Switched to
set-membership: `Count == 2` plus ShouldContain for each expected id.

## Root cause 4 (defensive): ShouldBeEqualWithDbPrecision tolerance

The helper used to round both sides to 100µs with truncation (`Ticks /
1000 * 1000`) and then ShouldBe. The math works in the common case, but
the assertion was fragile under loaded-runner clock-comparison edge
cases. Switched to a 1ms tolerance check; widely above the worst-case
PostgreSQL truncation (9 ticks ≈ 0.9µs) but still tight enough to catch
real semantic differences. Also produces a clearer failure message when
it does fire.

## Verification

Stress-ran the previously-flaky suites locally: 5x consecutive runs of
all 178 LinqTests.Bugs tests, no failures. All 123 tests across Bug_605,
Bug_4282, Bug_3337, query_against_child_collections, and
child_collection_queries pass. Bug_2283 in DocumentDbTests passes.

Closes #4310.

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.

Cold-start: index pre-generated types per assembly to avoid repeated O(n) ExportedTypes scans

1 participant