Performance Optimization Plan for Policy Engine ExtProc Processing #827
renuka-fernando
started this conversation in
General
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
-
Summary
This proposal defines a phased optimization plan for the Policy Engine's
ExternalProcessorServer.Process()function -- the hot path that handles every HTTP request flowing through the WSO2 API Platform gateway. The plan targets reduced per-request heap allocations, eliminated redundant computation, and improved throughput by introducing benchmarks, fixing duplicate work, adding object pooling, and implementing zero-allocation patterns for CEL evaluation and metadata construction.Motivation
Problem Statement
The Policy Engine sits on the critical data path between Envoy and upstream services. Every HTTP request triggers a bidirectional gRPC stream that allocates multiple objects on the heap, parses protobuf metadata, evaluates CEL conditions, and constructs response messages -- all from scratch on every request.
Specific issues identified through code analysis:
No performance baseline exists. There are zero benchmark tests in the codebase. Without benchmarks, there is no way to measure current performance, detect regressions, or validate optimizations.
Redundant computation on every request. The
extractRouteMetadata()function is called twice per request (once atextproc.go:143inhandleProcessingPhaseand again atextproc.go:331insideinitializeExecutionContext). Each call runsprototext.Unmarshalto parse route metadata from protobuf text format, involving astringto[]byteconversion and full proto parsing.Excessive per-request heap allocations. Every request allocates at minimum 10+ objects that are immediately garbage-collected:
PolicyExecutionContext,SharedContext,RequestContext,ResponseContext, headers maps (doubled due to normalization copy),Metadatamaps,Bodystructs, CEL evaluation maps (~20 keys each), executor result slices, and protobuf response messages. None of these objects are pooled withsync.Pool.CEL evaluation overhead even when unused. The
EvaluateRequestCondition()function ininternal/pkg/cel/evaluator.gocreates amap[string]interface{}with approximately 20 entries on every invocation. If no policies in a chain have execution conditions, this work is entirely wasted.Double header map copy.
buildRequestContext()creates amap[string][]stringfrom Envoy headers, thenpolicy.NewHeaders()(insdk/gateway/policy/v1alpha/headers.go:108) iterates the entire map again to normalize keys to lowercase into a new map. The same pattern applies tobuildResponseContext().Who Benefits
Why Now
The Policy Engine is feature-complete for its initial release and is entering production-readiness hardening. Performance characteristics should be established and optimized before production traffic scales up. The absence of any benchmarks means there is currently no way to detect performance regressions introduced by new features or refactors. Additionally, several of the identified issues (duplicate function calls, double header copy) are low-risk fixes that deliver immediate gains.
Detailed Design
Overview
The optimization plan is structured in four phases, ordered by risk and impact. Phase 0 establishes measurement infrastructure. Phase 1 eliminates redundant computation with no behavioral change. Phase 2 adds early-exit paths to skip unnecessary work. Phase 3 introduces
sync.Pool-based object reuse. Phase 4 explores advanced zero-allocation patterns. Each phase can be implemented, reviewed, and shipped independently.Changes Required
internal/kernel/extproc.goextractRouteMetadata()call; pass result intoinitializeExecutionContext()internal/kernel/extproc.gosync.Mapor LRU) keyed by route name to avoid per-requestprototext.Unmarshalinternal/kernel/extproc.goPolicyExecutionContextcreation when no policy chain exists; reduceskipAllProcessing()allocationsinternal/kernel/execution_context.goNewHeaders()internal/kernel/execution_context.gosync.PoolforPolicyExecutionContext,RequestContext,ResponseContext, andSharedContextinternal/executor/chain.goPolicySpecat config time; checkspan.IsRecording()before formattinginternal/executor/chain.goRequestExecutionResultandResponseExecutionResultslicesinternal/registry/chain.goHasExecutionConditions boolflag computed at chain build timeinternal/pkg/cel/evaluator.goActivationinterfaceinternal/kernel/analytics.gostructpb.Structfor analytics metadata; use protoCloneinstead of fresh constructioninternal/kernel/translator.gostructpballocations inbuildDynamicMetadata()via template cloningsdk/gateway/policy/v1alpha/headers.goNewHeaders()to accept pre-normalized keys directly, removing the internal re-normalization loopinternal/kernel/*_test.go(new)Process,extractRouteMetadata,buildRequestContext,buildResponseContext, CEL evaluation, policy chain execution, response translationDetailed Optimization Plan
Phase 0: Establish Baseline with Benchmarks
Goal: Create measurement infrastructure so every subsequent phase can prove its value.
Work items:
*_test.gowithBenchmark*functions) for:Processfunction end-to-end (using a mock gRPC stream)extractRouteMetadata(with realistic Envoy attributes)buildRequestContext(with typical header counts: 10, 25, 50 headers)buildResponseContext(with typical header counts)EvaluateRequestCondition,EvaluateResponseCondition)ExecuteRequestPolicies,ExecuteResponsePolicies)TranslateRequestHeadersActions,TranslateResponseHeadersActions)ns/op,B/op,allocs/opusinggo test -bench -benchmempprof) integration for targeted analysisExpected impact: No runtime impact. Provides the foundation for measuring all subsequent improvements.
Phase 1: Eliminate Redundant Computation
Goal: Remove duplicate calls, unnecessary parsing, and repeated allocations with no behavioral change.
1.1 Fix duplicate
extractRouteMetadata()callCurrently in
handleProcessingPhase(extproc.go:143):And
initializeExecutionContext(extproc.go:331) callsextractRouteMetadataagain:Fix: Change
initializeExecutionContextto accept aRouteMetadataparameter instead of extracting it internally.1.2 Cache parsed route metadata by route name
The
extractRouteMetadata()function at extproc.go:425 callsprototext.Unmarshal([]byte(metadataStr), &envoyMetadata)on every request. Route metadata for a given route name is static (it changes only when an API is redeployed). Async.Mapor size-bounded LRU cache keyed by route name eliminates this per-request parsing entirely.1.3 Pre-compute tracing span names at config time
In
chain.go:85:The
fmt.Sprintfallocates a new string per-policy per-request. Pre-compute these at config time and store them onPolicySpec(e.g.,spec.RequestSpanName,spec.ResponseSpanName).1.4 Pre-compute
HasExecutionConditionsflagAdd a
HasExecutionConditions boolfield toPolicyChainininternal/registry/chain.go, computed at chain build time. Whenfalse, the executor can skip all CEL evaluation logic entirely.1.5 Build headers with lowercase keys directly
In
buildRequestContext()(execution_context.go:318):Change to:
Then modify
NewHeaders(headersMap)in the SDK to skip the normalization loop entirely (breaking change accepted), since all callers will now provide pre-normalized keys. This eliminates the second map allocation. Apply the same pattern tobuildResponseContext().Expected impact:
prototext.Unmarshal+ proto allocation per request (item 1.1)prototext.Unmarshalon all subsequent requests for cached routes (item 1.2)fmt.Sprintfallocation per policy per phase (item 1.3)allocs/opfor the common casePhase 2: Skip Unnecessary Work
Goal: Add early-exit paths so requests that do not need certain processing skip it entirely.
2.1 Skip
PolicyExecutionContextcreation for routes without policiesWhen
GetPolicyChainForKey()returnsnil, the current code still callsextractRouteMetadata()and constructs aRouteMetadata. InskipAllProcessing()(extproc.go:356-384), it callsextractMetadataFromRouteMetadata()andstructpb.NewStruct()to build dynamic metadata even though no policies ran.Fix: Pre-build a minimal skip response with only route-level analytics that avoids
structpb.NewStruct()when the metadata map is empty or has only string values (use directstructpb.NewStringValueconstruction instead).2.2 Skip CEL evaluation context when no conditions exist
When
chain.HasExecutionConditionsisfalse(from Phase 1.4), the entire condition evaluation block inExecuteRequestPoliciesandExecuteResponsePoliciescan be skipped. This avoids themap[string]interface{}allocation with ~20 entries per CEL evaluation.2.3 Skip structpb allocations in
skipAllProcessing()When all analytics metadata values are strings, use direct
structpb.NewStringValue()construction instead of going throughstructpb.NewStruct()which validates and converts each value.2.4 Check span recording state before formatting attributes
The code already does this in several places (
if span.IsRecording()), but the span name string is computed viafmt.Sprintfbefore the span is created (chain.go:85). After Phase 1.3 pre-computes these strings, this is resolved. Additionally, verify all attribute-setting code paths are guarded.Expected impact:
PolicyExecutionContext+SharedContext+ map allocations for no-policy routes (item 2.1)structpboverhead for skip responses (item 2.3)allocs/opfor routes with no conditionsPhase 3: Object Pooling with sync.Pool
Goal: Reuse per-request objects across requests to reduce GC pressure.
3.1 Pool
PolicyExecutionContextCreate a
sync.PoolforPolicyExecutionContext. After each request completes (stream ends), reset the context fields and return it to the pool. TheReset()method must nil out all pointer fields and clear all maps.3.2 Pool
RequestContextandResponseContextSimilar pools for request and response contexts. These hold headers maps, body references, and shared context pointers.
3.3 Pool header maps
Pre-allocate
map[string][]stringwith a typical capacity (e.g., 16 entries) and return to pool after use.3.4 Pool CEL evaluation context maps
When CEL evaluation is needed, get a pre-allocated
map[string]interface{}from a pool, populate it, evaluate, then clear and return.3.5 Pool executor result slices
Pool
RequestExecutionResultandResponseExecutionResultwith pre-allocatedResultsslices.Expected impact:
B/op(bytes allocated per operation)Risk: Pool misuse (returning objects with stale references) can cause data races or memory corruption. Each pooled type needs a thorough
Reset()method and tests verifying clean state.Phase 4: Advanced Optimizations
Goal: Squeeze out remaining allocation overhead for maximum throughput.
4.1 Custom CEL
ActivationinterfaceThe CEL library supports custom
interpreter.Activationimplementations. Instead of building amap[string]interface{}with ~20 entries, implement anActivationthat reads directly fromRequestContextorResponseContextfields. This is a zero-allocation approach to CEL evaluation.4.2 Template-based structpb construction for analytics
Pre-build a
structpb.Structtemplate at config time with the fixed fields (API name, version, context, etc.) and useproto.Clone()per request, then fill in dynamic values. This avoids constructing the nested struct tree from scratch each time.4.3 Faster request ID generation
Replace
uuid.New().String()(which usescrypto/rand) with a faster alternative likegithub.com/rs/xidorgithub.com/oklog/ulidwhen cryptographic uniqueness is not required. Alternatively, useuuid.NewString()which avoids the intermediate[16]bytetostringconversion in newer UUID library versions.4.4 Proto message pooling for response construction
Pool
extprocv3.ProcessingResponse,extprocv3.HeadersResponse, andextprocv3.CommonResponseprotobuf messages. These follow predictable structures and can be reset and reused.Expected impact:
x-request-idheader existsallocs/opbeyond Phase 3Compatibility
NewHeaders()signature orHeadersinterface changes.NewHeaders()in the SDK will be modified to skip normalization (callers must provide lowercase keys). Internal interfaces such asPolicyExecutionContext, executor interfaces, and CEL evaluator signatures may change to support pooling and pre-computed fields. Adding new fields toPolicySpec(e.g.,RequestSpanName) andPolicyChain(e.g.,HasExecutionConditions) changes their struct layout. All breaking changes are internal to the gateway module or SDK and do not affect external APIs or configuration schemas.Migration Steps
Custom policy authors who import the SDK will need to recompile after the
NewHeaders()change. No code changes are required on their end since the function signature remains the same -- only the contract changes (keys must already be lowercase, which Envoy headers are by specification).References
gateway/policy-engine/internal/kernel/extproc.go- Main Process function and extractRouteMetadatagateway/policy-engine/internal/kernel/execution_context.go- PolicyExecutionContext and context buildersgateway/policy-engine/internal/executor/chain.go- Policy chain execution with CEL evaluationgateway/policy-engine/internal/pkg/cel/evaluator.go- CEL evaluator with per-request map allocationgateway/policy-engine/internal/kernel/translator.go- Response translation with structpb constructiongateway/policy-engine/internal/kernel/analytics.go- Analytics metadata buildinggateway/policy-engine/internal/registry/chain.go- PolicyChain struct definitionsdk/gateway/policy/v1alpha/headers.go- Headers normalization and NewHeaders constructorBeta Was this translation helpful? Give feedback.
All reactions