diff --git a/build.gradle b/build.gradle index 21d42af..920db3a 100644 --- a/build.gradle +++ b/build.gradle @@ -75,6 +75,13 @@ tasks.named('test') { useJUnitPlatform() } +tasks.register('loadGen', JavaExec) { + group = 'verification' + description = 'Run the plain gRPC load generator against an extproc server.' + mainClass = 'com.github.wrossmorrow.extproc.loadgen.LoadGen' + classpath = sourceSets.main.runtimeClasspath +} + test { useJUnit() useJUnitPlatform() diff --git a/src/main/java/com/github/wrossmorrow/extproc/loadgen/LoadGen.java b/src/main/java/com/github/wrossmorrow/extproc/loadgen/LoadGen.java new file mode 100644 index 0000000..a1ecdff --- /dev/null +++ b/src/main/java/com/github/wrossmorrow/extproc/loadgen/LoadGen.java @@ -0,0 +1,376 @@ +package com.github.wrossmorrow.extproc.loadgen; + +import build.buf.gen.envoy.config.core.v3.HeaderMap; +import build.buf.gen.envoy.config.core.v3.HeaderValue; +import build.buf.gen.envoy.config.core.v3.HeaderValueOption; +import build.buf.gen.envoy.service.ext_proc.v3.CommonResponse; +import build.buf.gen.envoy.service.ext_proc.v3.ExternalProcessorGrpc; +import build.buf.gen.envoy.service.ext_proc.v3.HttpBody; +import build.buf.gen.envoy.service.ext_proc.v3.HttpHeaders; +import build.buf.gen.envoy.service.ext_proc.v3.ProcessingRequest; +import build.buf.gen.envoy.service.ext_proc.v3.ProcessingResponse; +import com.google.protobuf.ByteString; +import io.grpc.ManagedChannel; +import io.grpc.ManagedChannelBuilder; +import io.grpc.stub.StreamObserver; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.logging.Logger; + +/** + * Plain gRPC load generator for the envoy ext_proc SDK (Java). + * + *

Drives full ext_proc exchanges over a raw bidirectional gRPC stream to the SDK's ext_proc + * server (`envoy.service.ext_proc.v3.ExternalProcessor/Process`), without an envoy in the path, and + * reports per-exchange latency and errors. + * + *

Each exchange mirrors the phase sequence the envoy ext_proc filter drives for a buffered + * request/response: request headers -> request body -> response headers -> response body, then the + * stream is closed. Because envoy (and the HTTP machinery in between) are absent, the reported + * latency is the processor-side cost of the SDK's gRPC server path, complementing the containerized + * k6 load tests in `loadtest` that drive the full envoy -> extproc -> upstream chain. + */ +public class LoadGen { + + private static final Logger logger = Logger.getLogger(LoadGen.class.getName()); + + /** + * The response header the server may set (via the ProcessingOptions.UpdateDurationHeader option) + * carrying the accumulated extproc processing time in nanoseconds. + */ + static final String DURATION_HEADER = "x-extproc-duration-ns"; + + static final String DEFAULT_ADDR = "127.0.0.1:50051"; + static final int DEFAULT_ITERATIONS = 100; + static final int DEFAULT_CONCURRENCY = 10; + + public static void main(String[] args) throws Exception { + String addr = DEFAULT_ADDR; + int iterations = DEFAULT_ITERATIONS; + int concurrency = DEFAULT_CONCURRENCY; + for (int i = 0; i < args.length; i++) { + String arg = args[i]; + if (arg.startsWith("--addr=")) { + addr = arg.substring("--addr=".length()); + } else if (arg.startsWith("--iterations=")) { + iterations = Integer.parseInt(arg.substring("--iterations=".length())); + } else if (arg.startsWith("--concurrency=")) { + concurrency = Integer.parseInt(arg.substring("--concurrency=".length())); + } + } + + if (iterations <= 0) { + System.err.println("LoadGen: iterations must be greater than zero"); + System.exit(1); + } + + System.out.println("LoadGen: target extproc gRPC server: http://" + addr); + System.out.println( + "LoadGen: running " + iterations + " exchanges with concurrency " + concurrency); + + int port = 50051; + String host = addr; + int colon = addr.lastIndexOf(':'); + if (colon >= 0) { + host = addr.substring(0, colon); + port = Integer.parseInt(addr.substring(colon + 1)); + } + ManagedChannel channel = ManagedChannelBuilder.forAddress(host, port).usePlaintext().build(); + ExternalProcessorGrpc.ExternalProcessorStub stub = ExternalProcessorGrpc.newStub(channel); + ExecutorService executor = Executors.newFixedThreadPool(concurrency); + try { + List workerResults = new ArrayList<>(); + List> futures = new ArrayList<>(); + for (int i = 0; i < concurrency; i++) { + final int base = i; + final ExternalProcessorGrpc.ExternalProcessorStub workerStub = stub; + final int workerIterations = iterations; + final int workerConcurrency = concurrency; + futures.add( + executor.submit( + () -> runWorker(workerStub, base, workerConcurrency, workerIterations))); + } + for (Future future : futures) { + workerResults.add(future.get(30, TimeUnit.SECONDS)); + } + + List latencies = new ArrayList<>(); + List extprocDurationsNs = new ArrayList<>(); + List errors = new ArrayList<>(); + for (WorkerResult result : workerResults) { + latencies.addAll(result.latencies); + extprocDurationsNs.addAll(result.extprocDurationsNs); + errors.addAll(result.errors); + } + latencies.sort((left, right) -> left.compareTo(right)); + double avgMs = 0.0; + if (!latencies.isEmpty()) { + Duration sum = Duration.ZERO; + for (Duration d : latencies) { + sum = sum.plus(d); + } + avgMs = sum.toMillis() / (double) latencies.size(); + } + Double p50Ms = percentile(latencies, 0.50); + Double p95Ms = percentile(latencies, 0.95); + double maxMs = latencies.isEmpty() ? 0.0 : latencies.get(latencies.size() - 1).toMillis(); + double avgDurationMs = 0.0; + if (!extprocDurationsNs.isEmpty()) { + long sumNs = 0; + for (long ns : extprocDurationsNs) { + sumNs += ns; + } + avgDurationMs = sumNs / ((double) extprocDurationsNs.size() * 1e6); + } + + for (ExchangeError e : errors) { + System.out.println("LoadGen: exchange " + e.index + " failed: " + e.error); + } + System.out.println( + "LoadGen: finished: " + latencies.size() + " exchanges, " + errors.size() + " errors"); + System.out.println( + "LoadGen: latency avg=" + + avgMs + + "ms p50=" + + p50Ms + + "ms p95=" + + p95Ms + + "ms max=" + + maxMs + + "ms"); + System.out.println( + "LoadGen: extproc duration avg=" + + avgDurationMs + + "ms (" + + extprocDurationsNs.size() + + " samples)"); + + if (!errors.isEmpty()) { + System.exit(1); + } + } finally { + executor.shutdownNow(); + } + + channel.shutdownNow(); + } + + static WorkerResult runWorker( + ExternalProcessorGrpc.ExternalProcessorStub stub, int base, int stride, int iterations) { + WorkerResult result = new WorkerResult(new ArrayList<>(), new ArrayList<>(), new ArrayList<>()); + int index = base; + while (index < iterations) { + ExchangeOutcome outcome = runExchange(stub); + if (outcome.error != null) { + result.errors.add(new ExchangeError(index, outcome.error)); + } else { + result.latencies.add(outcome.latency); + if (outcome.extprocDurationNs != null) { + result.extprocDurationsNs.add(outcome.extprocDurationNs); + } + } + index += stride; + } + return result; + } + + static ExchangeOutcome runExchange(ExternalProcessorGrpc.ExternalProcessorStub stub) { + Instant start = Instant.now(); + ResponseCollector responseObserver = new ResponseCollector(); + StreamObserver requestObserver = stub.process(responseObserver); + + // phase 1: request headers (not yet end of stream) + System.out.println("LoadGen: exchange starting, sending phase 1"); + responseObserver.latch = new CountDownLatch(1); + requestObserver.onNext( + ProcessingRequest.newBuilder() + .setRequestHeaders( + HttpHeaders.newBuilder() + .setHeaders(requestHeaderMap("POST", "/test")) + .setEndOfStream(false) + .build()) + .build()); + awaitExchange(responseObserver); + ProcessingResponse response = responseObserver.lastResponse; + System.out.println("LoadGen: got response for phase 1: " + response.getResponseCase()); + + // phase 2: request body (end of stream) + responseObserver.latch = new CountDownLatch(1); + requestObserver.onNext( + ProcessingRequest.newBuilder() + .setRequestBody( + HttpBody.newBuilder() + .setBody(ByteString.copyFromUtf8("{}")) + .setEndOfStream(true) + .build()) + .build()); + awaitExchange(responseObserver); + response = responseObserver.lastResponse; + System.out.println("LoadGen: got response for phase 2: " + response.getResponseCase()); + + // phase 3: response headers (end of stream) + responseObserver.latch = new CountDownLatch(1); + requestObserver.onNext( + ProcessingRequest.newBuilder() + .setResponseHeaders(HttpHeaders.newBuilder().setEndOfStream(true).build()) + .build()); + awaitExchange(responseObserver); + response = responseObserver.lastResponse; + System.out.println("LoadGen: got response for phase 3: " + response.getResponseCase()); + + // phase 4: response body (end of stream) + responseObserver.latch = new CountDownLatch(1); + requestObserver.onNext( + ProcessingRequest.newBuilder() + .setResponseBody( + HttpBody.newBuilder() + .setBody(ByteString.copyFromUtf8("{}")) + .setEndOfStream(true) + .build()) + .build()); + requestObserver.onCompleted(); + awaitExchange(responseObserver); + response = responseObserver.lastResponse; + System.out.println("LoadGen: got response for phase 4: " + response.getResponseCase()); + + Long extprocDurationNs = extractDurationNs(response); + Duration latency = Duration.between(start, Instant.now()); + String error = responseObserver.error == null ? null : responseObserver.error.getMessage(); + return new ExchangeOutcome(latency, extprocDurationNs, error); + } + + static void awaitExchange(ResponseCollector responseObserver) { + System.out.println("LoadGen: awaiting processing response"); + try { + if (!responseObserver.latch.await(30, TimeUnit.SECONDS)) { + throw new RuntimeException("Timed out waiting for processing response"); + } + System.out.println("LoadGen: got processing response"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted while waiting for processing response", e); + } + } + + static Long extractDurationNs(ProcessingResponse response) { + if (response == null) { + return null; + } + CommonResponse common; + if (response.getResponseCase() == ProcessingResponse.ResponseCase.RESPONSE_HEADERS) { + common = response.getResponseHeaders().getResponse(); + } else if (response.getResponseCase() == ProcessingResponse.ResponseCase.RESPONSE_BODY) { + common = response.getResponseBody().getResponse(); + } else { + return null; + } + if (common == null || !common.hasHeaderMutation()) { + return null; + } + for (HeaderValueOption option : common.getHeaderMutation().getSetHeadersList()) { + HeaderValue header = option.getHeader(); + if (header == null || !DURATION_HEADER.equals(header.getKey())) { + continue; + } + ByteString raw = header.getRawValue(); + if (raw == null || raw.size() == 0) { + return null; + } + try { + return Long.valueOf( + new String(raw.toByteArray(), java.nio.charset.StandardCharsets.UTF_8).trim()); + } catch (Exception e) { + return null; + } + } + return null; + } + + static HeaderMap requestHeaderMap(String method, String path) { + HeaderMap.Builder builder = HeaderMap.newBuilder(); + builder.addHeaders( + HeaderValue.newBuilder() + .setKey(":method") + .setRawValue(ByteString.copyFromUtf8(method)) + .build()); + builder.addHeaders( + HeaderValue.newBuilder() + .setKey(":path") + .setRawValue(ByteString.copyFromUtf8(path)) + .build()); + return builder.build(); + } + + static double percentile(List sorted, double percentile) { + if (sorted.isEmpty()) { + return 0.0; + } + int rank = (int) Math.round((double) (sorted.size() - 1) * percentile); + return sorted.get(rank).toMillis(); + } + + static final class ResponseCollector implements StreamObserver { + ProcessingResponse lastResponse; + Throwable error; + boolean completed; + CountDownLatch latch = new CountDownLatch(0); + + @Override + public void onNext(ProcessingResponse value) { + lastResponse = value; + latch.countDown(); + } + + @Override + public void onError(Throwable t) { + error = t; + } + + @Override + public void onCompleted() { + completed = true; + } + } + + static final class WorkerResult { + final List latencies; + final List extprocDurationsNs; + final List errors; + + WorkerResult( + List latencies, List extprocDurationsNs, List errors) { + this.latencies = latencies; + this.extprocDurationsNs = extprocDurationsNs; + this.errors = errors; + } + } + + static final class ExchangeError { + final int index; + final String error; + + ExchangeError(int index, String error) { + this.index = index; + this.error = error; + } + } + + static final class ExchangeOutcome { + final Duration latency; + final Long extprocDurationNs; + final String error; + + ExchangeOutcome(Duration latency, Long extprocDurationNs, String error) { + this.latency = latency; + this.extprocDurationNs = extprocDurationNs; + this.error = error; + } + } +} diff --git a/src/main/java/com/github/wrossmorrow/extproc/processors/TestingRequestProcessor.java b/src/main/java/com/github/wrossmorrow/extproc/processors/TestingRequestProcessor.java new file mode 100644 index 0000000..eafaf62 --- /dev/null +++ b/src/main/java/com/github/wrossmorrow/extproc/processors/TestingRequestProcessor.java @@ -0,0 +1,270 @@ +package com.github.wrossmorrow.extproc.processors; + +import build.buf.gen.envoy.service.ext_proc.v3.ProcessingRequest.RequestCase; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.wrossmorrow.extproc.ProcessingOptions; +import com.github.wrossmorrow.extproc.RequestContext; +import com.github.wrossmorrow.extproc.RequestProcessor; +import com.github.wrossmorrow.extproc.RequestProcessorHealthManager; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * Request processor used by the testing harness. Mirrors the Go and Rust SDKs' tester processors, + * which drive full ext_proc exchanges (via the load generator) and mutate the stream through the + * processing actions encoded in the request body. + */ +public class TestingRequestProcessor implements RequestProcessor { + + private static final Logger logger = Logger.getLogger(TestingRequestProcessor.class.getName()); + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + /** Request processing actions applied by the tester across the phases of a single exchange. */ + public static final class TesterRequestBody { + + @JsonProperty("append_request_headers") + public Map appendRequestHeaders; + + @JsonProperty("add_request_headers") + public Map addRequestHeaders; + + @JsonProperty("overwrite_request_headers") + public Map overwriteRequestHeaders; + + @JsonProperty("remove_request_headers") + public List removeRequestHeaders; + + @JsonProperty("clear_request_body") + public boolean clearRequestBody; + + @JsonProperty("replace_request_body") + public String replaceRequestBody; + + @JsonProperty("append_response_headers") + public Map appendResponseHeaders; + + @JsonProperty("add_response_headers") + public Map addResponseHeaders; + + @JsonProperty("overwrite_response_headers") + public Map overwriteResponseHeaders; + + @JsonProperty("remove_response_headers") + public List removeResponseHeaders; + + @JsonProperty("clear_response_body") + public boolean clearResponseBody; + + @JsonProperty("replace_response_body") + public String replaceResponseBody; + + @JsonProperty("cancel_request") + public boolean cancelRequest; + + @JsonProperty("cancel_request_status") + public int cancelRequestStatus; + + @JsonProperty("cancel_request_headers") + public Map cancelRequestHeaders; + + @JsonProperty("cancel_request_body") + public String cancelRequestBody; + } + + private final ProcessingOptions options; + private volatile TesterRequestBody actions; + + public TestingRequestProcessor() { + this(new ProcessingOptions()); + } + + public TestingRequestProcessor(ProcessingOptions options) { + this.options = options; + } + + @Override + public String getName() { + return "testing"; + } + + @Override + public ProcessingOptions getOptions() { + return options; + } + + @Override + public void setHealthManager(RequestProcessorHealthManager health) {} + + @Override + public void shutdown() { + logger.info(TestingRequestProcessor.class.getSimpleName() + " shutting down"); + } + + @Override + public void close(int gracePeriodSeconds) { + logger.info("Closing tester"); + } + + @Override + public void errorHandler(RequestContext ctx, RequestCase phase, Throwable err) { + logger.log(Level.INFO, "Error in phase {0}: {1}", new Object[] {phase, err}); + } + + @Override + public void processRequestHeaders(RequestContext ctx, Map headers) { + reportHeaderParsing(ctx, headers); + + // optional upstream override, exercising clear-route-cache driven upstream + // changes: rewrite the authority and clear the route cache so envoy + // recomputes the route + String upstream = getHeaderIgnoreCase(headers, "x-route-to-upstream"); + if (upstream != null) { + ctx.overwriteHeader(":authority", upstream); + ctx.clearRouteCache(); + } + + ctx.continueAndReplace(); + } + + @Override + public void processRequestBody(RequestContext ctx, String body) { + // NOTE: assuming buffering, not streaming; if buffering we have to + // accumulate the request body chunks sent until the end of stream + + TesterRequestBody parsed = parseActions(body); + ctx.setValue("actions", parsed); + + if (parsed != null && parsed.cancelRequest) { + ctx.cancelRequest( + parsed.cancelRequestStatus, parsed.cancelRequestHeaders, parsed.cancelRequestBody); + return; + } + + if (parsed != null) { + applyRequestHeaderActions(ctx, parsed); + applyRequestBodyActions(ctx, parsed); + } + + ctx.continueAndReplace(); + } + + @Override + public void processRequestTrailers(RequestContext ctx, Map trailers) { + ctx.continueAndReplace(); + } + + @Override + public void processResponseHeaders(RequestContext ctx, Map headers) { + TesterRequestBody parsed = (TesterRequestBody) ctx.getValue("actions"); + if (parsed != null) { + applyResponseHeaderActions(ctx, parsed); + } + ctx.continueAndReplace(); + } + + @Override + public void processResponseBody(RequestContext ctx, String body) { + TesterRequestBody parsed = (TesterRequestBody) ctx.getValue("actions"); + if (parsed != null) { + applyResponseBodyActions(ctx, parsed); + } + ctx.continueAndReplace(); + } + + @Override + public void processResponseTrailers(RequestContext ctx, Map trailers) { + ctx.continueAndReplace(); + } + + /** Reflect on how the SDK parsed the inbound headers back via the request headers. */ + protected void reportHeaderParsing(RequestContext ctx, Map headers) { + ctx.addHeader("x-parsed-str-count", String.valueOf(ctx.getAllRequestHeaders().size())); + String first = ctx.getAllRequestHeaders().get("x-probe-list"); + ctx.addHeader("x-parsed-list-value", first == null ? "" : first); + List values = ctx.getAllRequestHeaders().values("x-probe-list"); + ctx.addHeader("x-parsed-list-count", String.valueOf(values.size())); + List split = ctx.getAllRequestHeaders().splitList("x-probe-list"); + ctx.addHeader("x-parsed-list-split", String.join("|", split)); + } + + protected TesterRequestBody parseActions(String body) { + if (body == null || body.isEmpty()) { + return null; + } + try { + JsonNode node = MAPPER.readTree(body); + if (node == null || node.isNull() || !node.isObject()) { + return null; + } + TesterRequestBody actions = MAPPER.convertValue(node, TesterRequestBody.class); + return actions; + } catch (Exception e) { + logger.log(Level.WARNING, "Error parsing request body: {0}", e.getMessage()); + return null; + } + } + + protected void applyRequestHeaderActions(RequestContext ctx, TesterRequestBody actions) { + if (actions.appendRequestHeaders != null) { + ctx.appendHeaders(actions.appendRequestHeaders); + } + if (actions.addRequestHeaders != null) { + ctx.addHeaders(actions.addRequestHeaders); + } + if (actions.overwriteRequestHeaders != null) { + ctx.overwriteHeaders(actions.overwriteRequestHeaders); + } + if (actions.removeRequestHeaders != null) { + ctx.removeHeaders(actions.removeRequestHeaders); + } + } + + protected void applyResponseHeaderActions(RequestContext ctx, TesterRequestBody actions) { + if (actions.appendResponseHeaders != null) { + ctx.appendHeaders(actions.appendResponseHeaders); + } + if (actions.addResponseHeaders != null) { + ctx.addHeaders(actions.addResponseHeaders); + } + if (actions.overwriteResponseHeaders != null) { + ctx.overwriteHeaders(actions.overwriteResponseHeaders); + } + if (actions.removeResponseHeaders != null) { + ctx.removeHeaders(actions.removeResponseHeaders); + } + } + + protected void applyRequestBodyActions(RequestContext ctx, TesterRequestBody actions) { + if (actions.clearRequestBody) { + ctx.clearBody(); + } else if (actions.replaceRequestBody != null) { + ctx.replaceBody(actions.replaceRequestBody); + } + } + + protected void applyResponseBodyActions(RequestContext ctx, TesterRequestBody actions) { + if (actions.clearResponseBody) { + ctx.clearBody(); + } else if (actions.replaceResponseBody != null) { + ctx.replaceBody(actions.replaceResponseBody); + } + } + + /** Case-insensitive header lookup over the phase header map. */ + protected String getHeaderIgnoreCase(Map headers, String name) { + if (headers == null) { + return null; + } + for (Map.Entry entry : headers.entrySet()) { + if (entry.getKey() != null && entry.getKey().equalsIgnoreCase(name)) { + return entry.getValue(); + } + } + return null; + } +}