Repository navigation
Description
Summary
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/ThenByran on every invocation even though policies rarely change at runtime. - Double async state machine -
EvaluatePolicyAsync->InvokePolicyAsynccreated two async state machines even for synchronous policies with no timeout configured. - Sync policy
Taskallocation - sync policies' defaultEvaluateAsyncwrapsEvaluateviaTask.FromResult, allocating newTask<PolicyDecision>per call. PolicyDecision.Allow()allocation - the parameterlessAllow()created new heap object every time.- Audit/history noise in benchmarks -
InMemoryAuditorandInMemoryHistoryStorehaveIsEnabled => true, causing audit entry + history entry allocation per successful execution even when no policies are registered.
Scope
PolicyDecision (src/Abstractions/Policies/PolicyDecision.cs):
- Add
private static readonly PolicyDecision _allowsingleton for parameterlessAllow()calls returns cached instance instead ofnew PolicyDecision { IsAllowed = true }.
MutationPolicyEvaluator (src/Runtime/Internal/Evaluation/MutationPolicyEvaluator.cs):
- Cache sorted
IMutationPolicy<TState>[]per state type viaConcurrentDictionary<Type, object>registry query + sort hit once per type, then zero per call sorting overhead. - Return
ValueTask<PolicyDecision>instead ofTask<PolicyDecision>sync completions wrap the result directly withoutTaskallocation. - Check
task.IsCompletedSuccessfullybeforeawaitsynchronous policies (defaultEvaluateAsyncreturnsTask.FromResult) complete viatask.Result, avoiding async state machine +Task<PolicyDecision>allocation. - Eliminate
InvokePolicyAsyncwrapper merge try/catch inline inEvaluateNoTimeoutAsync. - Handle sync throws from
EvaluateAsyncwith inline try catch at each call site. - Split into
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.- Timing recording happens inline in the sync path via
evaluateTask.Result; a separateAwaitPolicyAndRecordAsynchandles the async-only case.
Benchmarks/Policy/PolicyBenchmarkSupport.cs:
SyncAllowBenchmarkPolicy/AsyncAllowBenchmarkPolicycachePolicyDecision+Task<PolicyDecision>per instance zero allocation per evaluation.- Register
NoOpAuditor+NoOpHistoryStoreinBuildEngineeliminates 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
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). - No public API or interface changes.
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 allocation -
MutationPolicyEvaluatorcaches sorted policies per typeGetPolicies+ sort overhead incurred once per type - Sync policies complete via
IsCompletedSuccessfullycheck withoutawait -
EvaluateAsyncreturnsValueTask<PolicyDecision>zeroTask<PolicyDecision>allocation for sync completion - No double async state machine
InvokePolicyAsyncremoved, 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
SemaphoreSlimeviction inMutationExecutionConcurrencyGate - This issue does not touch
StateSizeEstimator.Estimatecall 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.
Activity