Repository navigation
Description
Summary
Eliminate per call heap allocations associated with MutationResult<TState> creation and MutationHistoryEntry/MutationAuditEntry materialization by converting output DTOs to readonly record struct, caching singletons for success/empty defaults, and pre caching supporting objects in benchmark setup.
Goal
Reduce result creation time by ~79–98% and allocation to 0 B across all benchmark scenarios. Reduce materialization time by ~96–97% and allocation to 0 B.
Problem
Every MutationResult<TState>.Success() / .Failure() / .PolicyBlocked() call and every history/audit entry materialization incurred several unnecessary heap allocations:
MutationResult<TState>was class every result creation allocated new heap object including its internal default initializedValidationResult,MutationMetrics(withDictionary), and empty collections.ValidationResult.Success()created new instance each call allocatedValidationResultwith three emptyList<T>instances.MutationMetricsdefault initialized aDictionaryevery result carried heap allocated empty dictionary even when no metrics were recorded.ChangeSet.Emptycreated a new instance used as default property value on every result, even when immediately replaced.MutationHistoryEntryandMutationAuditEntrywere classes materializing audit or history output allocated the entry object plus all supporting objects (MutationIntent,MutationContext, side-effect list copy).SideEffect.ResolveContractran reflection everySideEffect.Create()calledGetCustomAttributesto resolve contract metadata.CreateIntent()/CreateContext()called per iteration in benchmarks allocatedMutationIntent(withHashSet+Dictionary) andMutationContext(withDictionary) each benchmark iteration.
Scope
MutationResult<TState> (src/Abstractions/Results/MutationResult.cs):
- Convert from
sealed record(class) toreadonly record structremoves the per instance heap allocation associated with result object creation. - Add
= []property initializers onPolicyDecisionsandSideEffects, plus explicit parameterless.
ValidationResult (src/Abstractions/Results/ValidationResult.cs):
- Add
private static readonly ValidationResult _success = new()singletonSuccess()returns the cached instance instead ofnew ValidationResult().
MutationMetrics (src/Abstractions/Metrics/MutationMetrics.cs):
- Add
internal static readonly MutationMetrics Empty = new()used as default in place ofnew(). - Add
private static readonly IReadOnlyDictionary<string, object> _emptyAdditionalMetricsshared empty dictionary default.
ChangeSet (src/Abstractions/Changes/ChangeSet.cs):
- Add
private static readonly ChangeSet _empty = new()singletonEmptyproperty returns cached instance.
SideEffect (src/Abstractions/Effects/SideEffect.cs):
- Add
ConcurrentDictionary<Type, (string?, int?)> _contractCacheResolveContractlooks up the cache before running reflection is performed once per type in steady state. Concurrent first-use races may perform duplicate harmless reflection work viaTryAdd.
MutationHistoryEntry (src/Abstractions/History/MutationHistoryEntry.cs):
- Convert from
sealed classtoreadonly record structmaterialized as value type, eliminating the entry object heap allocation.
MutationAuditEntry (src/Abstractions/Audit/MutationAuditEntry.cs):
- Convert from
sealed classtoreadonly record structmaterialized as value type, eliminating the entry object heap allocation.
MutationHistory (src/Abstractions/History/MutationHistory.cs):
FirstMutationAt/LastMutationAtreplacedFirstOrDefault()?.Timestamp/LastOrDefault()?.TimestampwithEntries.Count > 0 ? Entries[0].Timestamp : null/Entries[^1].Timestamppattern (structFirstOrDefault()returnsdefault(T)so?.is inapplicable).
Benchmarks/Results/MutationResultCreationBenchmarks.cs:
- Pre cache side-effect lists in
[GlobalSetup]benchmark body only measuresMutationResult<TState>.Success()with already existing references.
Benchmarks/Results/MutationOutputMaterializationBenchmarks.cs:
- Pre-cache
MutationIntent,MutationContext,SideEffects.ToList()in[GlobalSetup]benchmark body only measuresnew MutationHistoryEntry { ... }/new MutationAuditEntry { ... }with already-existing references.
Design Expectations
MutationResult<TState>is value type. Itsdefaultvalue contains null references for reference type fields, so factory methods must explicitly initialize all required reference properties. Property initializers (= []) and the explicit parameterless constructor ensure thatnew MutationResult<TState>()produces valid empty collections and defaults, whiledefaultremains zero initialized struct.- No method signatures or interface contracts changed. Public result and output types retain their existing members, but their underlying representation changes from reference types to value types.
MutationHistoryEntry/MutationAuditEntryas structs meansdefault(T)produces all null fields consumers must usenew() { ... }syntax with explicit property values (existing usage already does this).Entries.FirstOrDefault()?.Timestampno longer compiles for structTreplaced with explicitCount > 0guard.- No boxing occurs when stored in generic containers (
IReadOnlyList<T>,List<T>,Dictionary<,List<T>>). Boxing would occur only if passed to non genericIEnumerable,object, orIEqualityComparerpaths. - No existing
is nullchecks orascasts are used on either type zero consumer breakage. - Materialization benchmarks isolate entry value type construction from supporting object creation by pre caching
Intent,Context, and side effect lists in[GlobalSetup]. Real pipeline execution also incurs these one-time allocation costs per mutation, but they are not per materialization costs. MutationResult<TState>creation itself introduces no heap allocation because all reference-type fields point to existing cached instances (ValidationResult.Success(),MutationMetrics.Empty,Array.Empty<PolicyDecision>, etc.) rather than newly created objects.
Singleton Safety (Mutability Verification)
The following cached singletons share mutable internal state and must not be mutated:
| Singleton | Internal State | Mutation Methods | Risk |
|---|---|---|---|
ValidationResult._success |
3× List<T> (private readonly) |
AddError, AddWarning, AddInfo |
Low no call site invokes .Success().Add*(...) in current codebase |
ChangeSet._empty |
List<StateChange> (private readonly) |
Add(StateChange) |
Low — ChangeSet.Empty is read only in all usages |
If defensive protection is required, follow up can freeze the internal lists (eg., ImmutableList<T>, or flag that causes Add to throw). This is not addressed in the current scope.
Acceptance Criteria
-
MutationResult<TState>isreadonly record structzero heap allocation perSuccess()/Failure()/PolicyBlocked() -
ValidationResult.Success()returns cached singleton 0 allocation per call -
MutationMetrics.Emptyis cached singleton 0 allocation per result -
ChangeSet.Emptyis a cached singleton 0 allocation per use -
SideEffect.ResolveContractusesConcurrentDictionarycache reflection hit once per type -
MutationHistoryEntryisreadonly record structzero allocation per materialization -
MutationAuditEntryisreadonly record structzero allocation per materialization - All Creation benchmarks show 0 B allocated across all 3 cases
- All Materialization benchmarks show 0 B allocated across both cases
- All existing unit tests pass without modification
Benchmark regression validated:
| Benchmark | Before | After | Δ Time | Δ Alloc |
|---|---|---|---|---|
Success_NoSideEffects |
88.25 ns / 528 B | 18.61 ns / 0 B | −79% | −100% |
Success_SingleSideEffect |
280.39 ns / 792 B | 18.72 ns / 0 B | −93% | −100% |
Success_MultipleSideEffects |
899.22 ns / 1960 B | 18.79 ns / 0 B | −98% | −100% |
HistoryEntry_Materialization |
688.7 ns / 896 B | 23.40 ns / 0 B | −97% | −100% |
AuditEntry_Materialization |
666.0 ns / 848 B | 24.47 ns / 0 B | −96% | −100% |
Non Goals
- This issue does not change
IMutationHistoryStoreorIMutationAuditorinterfaces - This issue does not address
StateSizeEstimator.Estimatecall guards - This issue does not optimize
PolicyModificationApplier.Applyheap allocation - This issue does not change
MutationEngine,MutationExecutionPipeline, or any execution orchestration code - This issue does not introduce breaking changes to method signatures or interfaces (underlying type representation changes from reference to value types, but no API surface changes)
Note
- Struct conversion of
MutationResult<TState>required adding= []property initializers onPolicyDecisionsandSideEffects, plus an explicitpublic MutationResult() { }parameterless constructor. MutationHistory.csFirstMutationAt/LastMutationAtusedFirstOrDefault()?.Timestampwith structMutationHistoryEntry,FirstOrDefault()returnsdefault(T)(cannot be null), so?.is invalid. Replaced withEntries.Count > 0 ? Entries[0].Timestamp : null.ConcurrentDictionary.TryAddmeans concurrent first use may run duplicate reflection results are idempotent and harmless.
Activity