Repository navigation
[Perf]: Eliminate audit and history entry allocations when auditor/store is disabled #90
Description
Summary
Eliminate per mutation heap allocations from MutationAuditEntry and MutationHistoryEntry creation when the registered auditor or history store does not consume them ( NoOpAuditor/NoOpHistoryStore). Remove redundant defensive copy in CreateHistoryEntry.
Goal
Lower latency and GC pressure for the common case where diagnostics are configured but no real auditor/store is attached, without breaking existing public API contracts.
Problem
Every mutation execution unconditionally allocates MutationAuditEntry (via CreateSuccess) and MutationHistoryEntry (via CreateHistoryEntry) objects, even when the registered IMutationAuditor and IMutationHistoryStore implementations are no-ops that discard the data immediately. The factory runs first, allocates, then passes the result to a consumer that ignores it.
Additionally, CreateHistoryEntry called result.SideEffects.ToList() which allocated a new List<SideEffect> on every commit a redundant copy of an already read only collection.
IMutationAuditor (src/Abstractions/Audit/IMutationAuditor.cs):
- Add
bool IsEnabled => true;default interface method - Implementations that do not consume entries override to return
false
IMutationHistoryStore (src/Abstractions/History/IMutationHistoryStore.cs):
- Add
bool IsEnabled => true;default interface method - Implementations that do not consume entries override to return
false
MutationExecutionOutcomeProcessor (src/Runtime/Internal/Execution/MutationExecutionOutcomeProcessor.cs):
- Guard
AuditSuccessAsync/AuditFailureAsyncwithif (!_auditor.IsEnabled) return; - Guard
StoreInHistoryAsyncwithif (!_historyStore.IsEnabled) return;
MutationAuditEntryFactory (src/Runtime/Diagnostics/MutationAuditEntryFactory.cs):
- Replace
result.SideEffects.ToList()with existingIReadOnlyListinstance no defensive copy
NoOpAuditor / NoOpHistoryStore (Benchmarks/Diagnostics/Support/):
- Add
bool IsEnabled => false;override
Important
Design Expectations
- Hot path with
NoOpAuditor/NoOpHistoryStoremust allocate zero bytes for audit/history entries perExecuteAsync. IsEnableduses default interface method existing implementors are unaffected and do not require source changes.- Allocations for enabled auditors (e.g.
InMemoryAuditor) remain unchanged entries are still created because they are consumed. - No breaking public API changes. Additive
IsEnableddefault interface properties preserve compatibility with existing implementations.
Acceptance Criteria
-
OutcomeProcessorskipsCreateSuccesswhen_auditor.IsEnabledisfalse -
OutcomeProcessorskipsCreateFailurewhen_auditor.IsEnabledisfalse -
OutcomeProcessorskipsCreateHistoryEntrywhen_historyStore.IsEnabledisfalse -
CreateHistoryEntryno longer calls.ToList()on side effects -
NoOpAuditorandNoOpHistoryStoredeclareIsEnabled => false - All existing unit tests pass without modification
- Benchmark regression validated:
| Benchmark | Before | After | Δ |
|---|---|---|---|
NoDiagnostics_Baseline |
2.237 us / — | 1.430 us / 2.55 KB | -36.1% |
AuditHistory_Enabled |
4.974 us / — | 3.660 us / 2.97 KB | -26.4% |
CombinedInterceptionAndDiagnostics_Enabled |
5.653 us / 4.01 KB | 3.966 us / 3.52 KB | -29.8% / -12.2% |
Allocation measurements for the previous baseline were not captured, so delta focuses on latency. The 2.55 KB after for
NoDiagnostics_Baselineincludes other required allocations audit/history entry creation itself is zero.
Non Goals
- This issue does not change
Task<T>toValueTask<T>. - This issue does not address the
withexpression allocation inFinalizeResultAsync. - This issue does not introduce any breaking public API changes.
Note
IsEnabled is default interface method with default return of true. No existing implementor needs to change. The NoDiagnostics_Baseline improvement is driven entirely by skipping factory calls NoOpAuditor + NoOpHistoryStore now avoid MutationAuditEntry, MutationHistoryEntry, and all intermediate allocations. Removing .ToList() from CreateHistoryEntry also benefits AuditHistory_Enabled and Combined*, though those still allocate entries because their auditor/store is InMemoryAuditor/InMemoryHistoryStore (both IsEnabled = true).
Activity