Skip to content

Defer aggregate cache compaction during composite projection execution#206

Merged
jeremydmiller merged 1 commit into
mainfrom
fix-composite-cache-eviction
May 7, 2026
Merged

Defer aggregate cache compaction during composite projection execution#206
jeremydmiller merged 1 commit into
mainfrom
fix-composite-cache-eviction

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Summary

Companion fix for JasperFx/marten#4329. When a composite projection's upstream stage finishes, its per-tenant RecentlyUsedCache was being compacted before downstream stages had a chance to read it. With a small CacheLimitPerTenant the compaction at end of stage 1 evicted entities that downstream stages were about to look up via EnrichWith<T>().AddReferences() or the new public TryFindUpstreamCache<TId, T>. Evicted ids fell through to storage.LoadManyAsync, which cannot see the upstream's queued in-flight writes — so the downstream lookup silently dropped them.

Reproducible from the Marten side by setting `Options.CacheLimitPerTenant = 1` on `AppointmentProjection` and running `multi_stage_projections.end_to_end` — `AppointmentByExternalIdentifier` ends up empty because nearly every stage-2 lookup misses both the cache and storage. See the diagnosis comment on #4329.

What changes

Composite-aware compaction:

  • `AggregationRunner.BuildBatchAsync` now skips end-of-batch `CompactIfNecessary` when `range.BatchBehavior == BatchBehavior.Composite`.
  • New `AggregationRunner.CompactCachesAsync` iterates the per-tenant `_caches` and compacts each.
  • New default-implemented `Task CompactCachesAsync()` on `ISubscriptionExecution` and `IGroupedProjectionRunner` (default = no-op) so existing callers/implementations don't need to change.
  • `GroupedProjectionExecution.CompactCachesAsync` forwards to the runner.
  • `CompositeExecution.buildBatchWithNoSkippingAsync` calls `CompactCachesAsync` on every child execution after all stages finish, restoring the per-tenant bound at the composite boundary.

Within a composite batch, every projection's cache stays at full size (one batch's worth of in-flight aggregates), then is compacted as a unit at composite end. Outside composites, behavior is unchanged.

Test plan

  • All 235 `EventTests` pass on net8.0 / net9.0 / net10.0.
  • End-to-end coverage lands in Marten via a follow-up integration test that runs `multi_stage_projections.end_to_end` with `CacheLimitPerTenant = 1` on the upstream — the same reproduction documented in the issue. (JasperFx-level unit-testing this would require building substantial AggregationRunner mock scaffolding for marginal additional confidence beyond the integration test.)

🤖 Generated with Claude Code

The per-projection RecentlyUsedCache was being compacted at the end of every
projection's BuildBatchAsync, including upstream stages of a composite. With a
small CacheLimitPerTenant the compaction at end of stage 1 evicted entities
that downstream stages were about to look up via EnrichWith<T>().AddReferences()
or the public TryFindUpstreamCache<TId, T> helper. Those evicted ids fell
through to storage.LoadManyAsync, which cannot see the upstream's queued
in-flight writes (the IProjectionBatch hasn't been flushed yet), so the
downstream lookup silently returned no entity and the slice was processed
without enrichment.

Reproducible from the Marten side by setting Options.CacheLimitPerTenant = 1
on AppointmentProjection and running multi_stage_projections.end_to_end —
AppointmentByExternalIdentifier ends up empty because nearly every stage-2
lookup misses both the cache and storage.

Fix: composite-aware compaction.
- AggregationRunner.BuildBatchAsync now skips end-of-batch CompactIfNecessary
  when range.BatchBehavior == BatchBehavior.Composite.
- New AggregationRunner.CompactCachesAsync iterates _caches and applies
  CompactIfNecessary to each per-tenant cache.
- New default-implemented Task CompactCachesAsync() on ISubscriptionExecution
  and IGroupedProjectionRunner so callers can opt in without breaking existing
  implementations.
- GroupedProjectionExecution forwards CompactCachesAsync to the runner.
- CompositeExecution.buildBatchWithNoSkippingAsync calls CompactCachesAsync
  on every child execution after all stages finish, restoring per-tenant
  bound at the composite boundary.

Refs JasperFx/marten#4329.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jeremydmiller jeremydmiller merged commit 1eb4897 into main May 7, 2026
1 check passed
@jeremydmiller jeremydmiller deleted the fix-composite-cache-eviction branch May 7, 2026 13:43
jeremydmiller added a commit that referenced this pull request May 7, 2026
Two changes ride this release:

1. Composite-projection cache eviction fix (#206): downstream stages of a
   composite no longer lose access to upstream in-flight entities when
   CacheLimitPerTenant is small relative to per-batch fan-out.

2. ForEntityIds (#208): first-class 1-event-to-N-entities enrichment via
   group.EnrichWith<T>().ForEvent<E>().ForEntityIds(selector).AddReferences().

Refs JasperFx/marten#4329, #206, #208.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
jeremydmiller added a commit to JasperFx/marten that referenced this pull request May 7, 2026
JasperFx.Events 1.35.0 ships two changes that close out the usability gap
@xander-abn surfaced in #4329:

1. Composite-projection cache eviction fix (JasperFx/jasperfx#206) —
   AppointmentByExternalIdentifier (and similar EnrichWith<UpstreamDoc>
   chains) no longer drop downstream lookups when the upstream's
   CacheLimitPerTenant is small relative to per-batch fan-out.

2. ForEntityIds (JasperFx/jasperfx#208) — first-class declarative shape for
   1-event-to-N-entities enrichment.

This commit:

- Bumps JasperFx.Events 1.34.0 → 1.35.0 in Directory.Packages.props.
- Adds Bug_4329_fan_out_and_cache_limit with two facts: a fan-out scenario
  using ForEntityIds (one OrderPlacedWithLineItems event referencing 5
  upstream Product documents), and a cache-eviction regression that
  intentionally sets ProductProjection.CacheLimitPerTenant = 1 and verifies
  20 distinct upstream lookups all resolve to the in-flight cache.
- Wires the new patterns into composite.md and enrichment.md, removes the
  now-stale "cache-limit is load-bearing for correctness" caveat, and
  adds a "Fan-out enrichment with ForEntityIds" section sourced from
  #region sample_for_entity_ids_fan_out in the new test.

Refs #4329, JasperFx/jasperfx#206, JasperFx/jasperfx#208.

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.

1 participant