You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Eliminate per call overhead in policy evaluation: cache sorted policies, skip async state machines for synchronous policies, remove redundant Task<PolicyDecision> and PolicyDecision allocations, and use NoOp auditor/history stores in benchmark isolation.
Goal
Reduce policy evaluation time by ~75% and allocation by ~45% across all scenarios. Flatten the scaling cost evaluating 4 policies should cost the same as evaluating 0.
Problem
Every EvaluateAsync call incurred several unnecessary costs:
Per call sorting - OrderByDescending / ThenBy ran on every invocation even though policies rarely change at runtime.
Double async state machine - EvaluatePolicyAsync -> InvokePolicyAsync created two async state machines even for synchronous policies with no timeout configured.
Sync policy Task allocation - sync policies' default EvaluateAsync wraps Evaluate via Task.FromResult, allocating new Task<PolicyDecision> per call.
PolicyDecision.Allow() allocation - the parameterless Allow() created new heap object every time.
Audit/history noise in benchmarks - InMemoryAuditor and InMemoryHistoryStore have IsEnabled => true, causing audit entry + history entry allocation per successful execution even when no policies are registered.
Cache sorted IMutationPolicy<TState>[] per state type via ConcurrentDictionary<Type, object> registry query + sort hit once per type, then zero per call sorting overhead.
Return ValueTask<PolicyDecision> instead of Task<PolicyDecision> sync completions wrap the result directly without Task allocation.
Check task.IsCompletedSuccessfully before await synchronous policies (default EvaluateAsync returns Task.FromResult) complete via task.Result, avoiding async state machine + Task<PolicyDecision> allocation.
Eliminate InvokePolicyAsync wrapper merge try/catch inline in EvaluateNoTimeoutAsync.
Handle sync throws from EvaluateAsync with inline try catch at each call site.
Split into EvaluateNoTimeoutAsync / AwaitRemainingAsync the first policy that does not complete synchronously triggers the async fallback; all-sync paths return a synchronously completed ValueTask.
EvaluatePolicyDecisionAsync returns ValueTask<PolicyDecision> with sync fast path eliminates inner Task<PolicyDecision> + async state machine when the evaluator completes synchronously.
Timing recording happens inline in the sync path via evaluateTask.Result; a separate AwaitPolicyAndRecordAsync handles the async-only case.
Benchmarks/Policy/PolicyBenchmarkSupport.cs:
SyncAllowBenchmarkPolicy / AsyncAllowBenchmarkPolicy cache PolicyDecision + Task<PolicyDecision> per instance zero allocation per evaluation.
Register NoOpAuditor + NoOpHistoryStore in BuildEngine eliminates audit entry + history entry allocation from the benchmark baseline, isolating policy evaluation cost.
Design Expectations
Policy evaluation for N synchronous allow policies must allocate the same as the no policy baseline the evaluator itself adds zero per-policy allocation.
The ConcurrentDictionary cache is populated once per state type; if policies change at runtime the cache becomes stale (acceptable for current usage policies are registered at engine startup and rarely mutated).
No public API or interface changes.
try/catch blocks guard synchronous throws from EvaluateAsync (rare; happens only when policy implementation throws rather than returning a faulted task). The IsCompletedSuccessfully fast path avoids await for the common synchronous allow case.
Acceptance Criteria
PolicyDecision.Allow() with no arguments returns cached singleton avoids per-call PolicyDecision heap allocation
MutationPolicyEvaluator caches sorted policies per type GetPolicies + sort overhead incurred once per type
Sync policies complete via IsCompletedSuccessfully check without await
EvaluateAsync returns ValueTask<PolicyDecision> zero Task<PolicyDecision> allocation for sync completion
No double async state machine InvokePolicyAsync removed, try/catch inlined
Policy evaluation benchmarks show at most 1.01× ratio vs baseline
All 4 policy benchmarks land at ~1.24 us / 1.66 KB
All existing unit tests pass without modification
Benchmark regression validated:
Benchmark
Before
After
Δ Time
Δ Alloc
NoPolicy_Baseline
5.007 us / 3.01 KB
1.226 us / 1.66 KB
−75%
−45%
SingleSyncPolicy_Allow
5.324 us / 3.98 KB
1.241 us / 1.66 KB
−77%
−58%
SingleAsyncPolicy_Allow
5.307 us / 3.98 KB
1.238 us / 1.66 KB
−77%
−58%
MultipleMixedPolicies_Allow
5.607 us / 5.43 KB
1.241 us / 1.66 KB
−78%
−69%
Non Goals
This issue does not change the public IMutationPolicy<TState> interface
This issue does not address ValueTask<T> for the interceptor pipeline
This issue does not address per-state SemaphoreSlim eviction in MutationExecutionConcurrencyGate
This issue does not touch StateSizeEstimator.Estimate call guards
This issue does not change any public API or interface
Note
The ConcurrentDictionary.TryGetValue + indexer set pattern has benign race: multiple threads may build the same immutable policy array concurrently during first access. Only one array is ultimately published for subsequent lookups duplicate builds are harmless and do not affect correctness or policy ordering.
Summary
Eliminate per call overhead in policy evaluation: cache sorted policies, skip async state machines for synchronous policies, remove redundant
Task<PolicyDecision>andPolicyDecisionallocations, and use NoOp auditor/history stores in benchmark isolation.Goal
Reduce policy evaluation time by ~75% and allocation by ~45% across all scenarios. Flatten the scaling cost evaluating 4 policies should cost the same as evaluating 0.
Problem
Every
EvaluateAsynccall incurred several unnecessary costs:OrderByDescending/ThenByran on every invocation even though policies rarely change at runtime.EvaluatePolicyAsync->InvokePolicyAsynccreated two async state machines even for synchronous policies with no timeout configured.Taskallocation - sync policies' defaultEvaluateAsyncwrapsEvaluateviaTask.FromResult, allocating newTask<PolicyDecision>per call.PolicyDecision.Allow()allocation - the parameterlessAllow()created new heap object every time.InMemoryAuditorandInMemoryHistoryStorehaveIsEnabled => true, causing audit entry + history entry allocation per successful execution even when no policies are registered.Scope
PolicyDecision(src/Abstractions/Policies/PolicyDecision.cs):private static readonly PolicyDecision _allowsingleton for parameterlessAllow()calls returns cached instance instead ofnew PolicyDecision { IsAllowed = true }.MutationPolicyEvaluator(src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs):IMutationPolicy<TState>[]per state type viaConcurrentDictionary<Type, object>registry query + sort hit once per type, then zero per call sorting overhead.ValueTask<PolicyDecision>instead ofTask<PolicyDecision>sync completions wrap the result directly withoutTaskallocation.task.IsCompletedSuccessfullybeforeawaitsynchronous policies (defaultEvaluateAsyncreturnsTask.FromResult) complete viatask.Result, avoiding async state machine +Task<PolicyDecision>allocation.InvokePolicyAsyncwrapper merge try/catch inline inEvaluateNoTimeoutAsync.EvaluateAsyncwith inline try catch at each call site.EvaluateNoTimeoutAsync/AwaitRemainingAsyncthe first policy that does not complete synchronously triggers the async fallback; all-sync paths return a synchronously completedValueTask.MutationExecutionPipeline(src/Runtime/Internal/Execution/MutationExecutionPipeline.cs):EvaluatePolicyDecisionAsyncreturnsValueTask<PolicyDecision>with sync fast path eliminates innerTask<PolicyDecision>+ async state machine when the evaluator completes synchronously.evaluateTask.Result; a separateAwaitPolicyAndRecordAsynchandles the async-only case.Benchmarks/Policy/PolicyBenchmarkSupport.cs:SyncAllowBenchmarkPolicy/AsyncAllowBenchmarkPolicycachePolicyDecision+Task<PolicyDecision>per instance zero allocation per evaluation.NoOpAuditor+NoOpHistoryStoreinBuildEngineeliminates audit entry + history entry allocation from the benchmark baseline, isolating policy evaluation cost.Design Expectations
ConcurrentDictionarycache is populated once per state type; if policies change at runtime the cache becomes stale (acceptable for current usage policies are registered at engine startup and rarely mutated).try/catchblocks guard synchronous throws fromEvaluateAsync(rare; happens only when policy implementation throws rather than returning a faulted task). TheIsCompletedSuccessfullyfast path avoidsawaitfor the common synchronous allow case.Acceptance Criteria
PolicyDecision.Allow()with no arguments returns cached singleton avoids per-callPolicyDecisionheap allocationMutationPolicyEvaluatorcaches sorted policies per typeGetPolicies+ sort overhead incurred once per typeIsCompletedSuccessfullycheck withoutawaitEvaluateAsyncreturnsValueTask<PolicyDecision>zeroTask<PolicyDecision>allocation for sync completionInvokePolicyAsyncremoved, try/catch inlinedBenchmark regression validated:
NoPolicy_BaselineSingleSyncPolicy_AllowSingleAsyncPolicy_AllowMultipleMixedPolicies_AllowNon Goals
IMutationPolicy<TState>interfaceValueTask<T>for the interceptor pipelineSemaphoreSlimeviction inMutationExecutionConcurrencyGateStateSizeEstimator.Estimatecall guardsNote
The ConcurrentDictionary.TryGetValue + indexer set pattern has benign race: multiple threads may build the same immutable policy array concurrently during first access. Only one array is ultimately published for subsequent lookups duplicate builds are harmless and do not affect correctness or policy ordering.