[AZ-1124] Add PT-10 gRPC stream perf scenario
ci/woodpecker/push/01-test Pipeline failed
ci/woodpecker/push/02-build-push unknown status

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-06-26 11:26:14 +03:00
parent a0449f79d0
commit 7dac986996
15 changed files with 598 additions and 11 deletions
@@ -1,3 +1,5 @@
using System.Net.Security;
using System.Security.Cryptography.X509Certificates;
using Grpc.Core;
using Grpc.Net.Client;
using Satellite.V1;
@@ -8,15 +10,65 @@ public static class GrpcTestHelpers
{
public static GrpcChannel CreateChannel(string apiUrl)
{
var handler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
};
var caCertPath = ResolveCaCertPath();
if (!string.IsNullOrEmpty(caCertPath))
{
var caCert = X509Certificate2.CreateFromPemFile(caCertPath);
handler.SslOptions = new SslClientAuthenticationOptions
{
RemoteCertificateValidationCallback = (_, certificate, _, errors) =>
{
if (errors == SslPolicyErrors.None)
{
return true;
}
if (certificate is null)
{
return false;
}
using var chain = new X509Chain();
chain.ChainPolicy.TrustMode = X509ChainTrustMode.CustomRootTrust;
chain.ChainPolicy.CustomTrustStore.Add(caCert);
chain.ChainPolicy.RevocationMode = X509RevocationMode.NoCheck;
return chain.Build(new X509Certificate2(certificate));
},
};
}
return GrpcChannel.ForAddress(apiUrl, new GrpcChannelOptions
{
HttpHandler = new SocketsHttpHandler
{
EnableMultipleHttp2Connections = true,
},
HttpHandler = handler,
});
}
private static string? ResolveCaCertPath()
{
var explicitPath = Environment.GetEnvironmentVariable("PERF_CA_CERT");
if (!string.IsNullOrWhiteSpace(explicitPath) && File.Exists(explicitPath))
{
return explicitPath;
}
var projectRoot = Environment.GetEnvironmentVariable("PROJECT_ROOT");
if (!string.IsNullOrWhiteSpace(projectRoot))
{
var defaultPath = Path.Combine(projectRoot, "certs", "api.crt");
if (File.Exists(defaultPath))
{
return defaultPath;
}
}
return null;
}
public static RouteTileDelivery.RouteTileDeliveryClient CreateClient(string apiUrl, string jwtToken)
{
var channel = CreateChannel(apiUrl);
@@ -1,4 +1,7 @@
using System.Diagnostics;
using System.Security.Claims;
using Grpc.Core;
using Satellite.V1;
using SatelliteProvider.TestSupport;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
@@ -94,4 +97,207 @@ internal static class PerfBootstrap
image.Save(stream, new JpegEncoder { Quality = 95 });
return stream.ToArray();
}
private static bool TryResolvePerfJwt(out string token)
{
var preMinted = Environment.GetEnvironmentVariable("PERF_JWT_TOKEN");
if (!string.IsNullOrWhiteSpace(preMinted))
{
token = preMinted;
return true;
}
try
{
var secret = JwtTestHelpers.ResolveSecretOrThrow();
var issuer = JwtTestHelpers.ResolveIssuerOrThrow();
var audience = JwtTestHelpers.ResolveAudienceOrThrow();
token = JwtTokenFactory.Create(
secret,
PerfSubject,
PerfTokenLifetime,
new[] { new Claim(PermissionsClaimType, GpsPermission) },
issuer: issuer,
audience: audience);
return true;
}
catch (InvalidOperationException ex)
{
Console.Error.WriteLine($"--run-pt10: {ex.Message}");
token = string.Empty;
return false;
}
}
public static async Task<int> RunPt10Async()
{
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "https://localhost:18980";
var repeatCount = int.TryParse(Environment.GetEnvironmentVariable("PERF_REPEAT_COUNT"), out var n) ? n : 20;
var slowMs = int.TryParse(Environment.GetEnvironmentVariable("PERF_PT10_SLOW_MS"), out var slow) ? slow : 50;
if (repeatCount < 1)
{
Console.Error.WriteLine("--run-pt10: PERF_REPEAT_COUNT must be >= 1");
return 1;
}
string token;
if (!TryResolvePerfJwt(out token))
{
return 1;
}
RouteTileDelivery.RouteTileDeliveryClient client;
try
{
client = GrpcTestHelpers.CreateClient(apiUrl, token);
}
catch (Exception ex)
{
Console.Error.WriteLine($"--run-pt10: failed to open gRPC channel to {apiUrl}: {ex.Message}");
return 1;
}
var headers = GrpcTestHelpers.AuthMetadata(token);
var firstBatchTimes = new List<long>();
var totalStreamTimes = new List<long>();
var failed = 0;
for (var i = 0; i < repeatCount; i++)
{
var result = await ProbeStreamAsync(client, headers, Guid.NewGuid(), delayMsBetweenEvents: null);
if (result.Success)
{
firstBatchTimes.Add(result.FirstBatchMs);
totalStreamTimes.Add(result.TotalStreamMs);
Console.Error.WriteLine($" iteration {i + 1}/{repeatCount}: first_batch_ms={result.FirstBatchMs} total_stream_ms={result.TotalStreamMs}");
}
else
{
failed++;
Console.Error.WriteLine($" iteration {i + 1}/{repeatCount}: FAILED — {result.FailureReason}");
}
}
if (firstBatchTimes.Count == 0)
{
Console.Error.WriteLine("--run-pt10: zero successful iterations");
Console.Out.WriteLine("PT10_ITERATIONS_OK=0");
Console.Out.WriteLine($"PT10_ITERATIONS_FAILED={failed}");
Console.Out.WriteLine("PT10_SLOW_CONSUMER=FAIL");
return 1;
}
var slowResult = await ProbeStreamAsync(client, headers, Guid.NewGuid(), delayMsBetweenEvents: slowMs);
var slowPass = slowResult.Success && slowResult.TileCount >= 1;
Console.Out.WriteLine($"PT10_FIRST_BATCH_P50={Percentile(firstBatchTimes, 50)}");
Console.Out.WriteLine($"PT10_FIRST_BATCH_P95={Percentile(firstBatchTimes, 95)}");
Console.Out.WriteLine($"PT10_TOTAL_STREAM_P50={Percentile(totalStreamTimes, 50)}");
Console.Out.WriteLine($"PT10_TOTAL_STREAM_P95={Percentile(totalStreamTimes, 95)}");
Console.Out.WriteLine($"PT10_ITERATIONS_OK={firstBatchTimes.Count}");
Console.Out.WriteLine($"PT10_ITERATIONS_FAILED={failed}");
Console.Out.WriteLine($"PT10_SLOW_CONSUMER={(slowPass ? "PASS" : "FAIL")}");
if (!slowPass)
{
Console.Error.WriteLine($"--run-pt10: slow-consumer check failed — {slowResult.FailureReason ?? "no tiles received"}");
return 1;
}
return failed > 0 ? 1 : 0;
}
internal static long Percentile(IReadOnlyList<long> values, int pct)
{
if (values.Count == 0)
{
return 0;
}
var sorted = values.OrderBy(v => v).ToList();
var idx = (int)Math.Ceiling(sorted.Count * pct / 100.0) - 1;
idx = Math.Clamp(idx, 0, sorted.Count - 1);
return sorted[idx];
}
private static async Task<StreamProbeResult> ProbeStreamAsync(
RouteTileDelivery.RouteTileDeliveryClient client,
Metadata headers,
Guid routeId,
int? delayMsBetweenEvents)
{
var request = GrpcTestHelpers.BuildValidRequest(routeId: routeId);
var sw = Stopwatch.StartNew();
long firstBatchMs = -1;
var tileCount = 0;
DeliveryComplete? complete = null;
DeliveryError? error = null;
try
{
using var call = client.DeliverRouteTiles(request, headers);
await foreach (var evt in call.ResponseStream.ReadAllAsync())
{
switch (evt.PayloadCase)
{
case RouteTileEvent.PayloadOneofCase.Batch:
if (firstBatchMs < 0)
{
firstBatchMs = sw.ElapsedMilliseconds;
}
tileCount += evt.Batch.Tiles.Count;
break;
case RouteTileEvent.PayloadOneofCase.Complete:
complete = evt.Complete;
break;
case RouteTileEvent.PayloadOneofCase.Error:
error = evt.Error;
break;
}
if (delayMsBetweenEvents.HasValue)
{
await Task.Delay(delayMsBetweenEvents.Value);
}
}
}
catch (RpcException ex)
{
return StreamProbeResult.Fail($"RpcException {ex.StatusCode}: {ex.Status.Detail}");
}
var totalStreamMs = sw.ElapsedMilliseconds;
if (error is not null)
{
return StreamProbeResult.Fail($"DeliveryError {error.Code}: {error.Message}");
}
if (complete is null)
{
return StreamProbeResult.Fail("stream ended without DeliveryComplete");
}
if (firstBatchMs < 0)
{
return StreamProbeResult.Fail("no Batch event before DeliveryComplete");
}
return StreamProbeResult.Ok(firstBatchMs, totalStreamMs, tileCount);
}
private readonly record struct StreamProbeResult(
bool Success,
long FirstBatchMs,
long TotalStreamMs,
int TileCount,
string? FailureReason)
{
public static StreamProbeResult Ok(long firstBatchMs, long totalStreamMs, int tileCount) =>
new(true, firstBatchMs, totalStreamMs, tileCount, null);
public static StreamProbeResult Fail(string reason) =>
new(false, 0, 0, 0, reason);
}
}
@@ -19,6 +19,10 @@ class Program
{
return PerfBootstrap.GenerateUavFixture(args);
}
if (args[0].Equals("--run-pt10", StringComparison.OrdinalIgnoreCase))
{
return await PerfBootstrap.RunPt10Async();
}
}
var apiUrl = Environment.GetEnvironmentVariable("API_URL") ?? "https://api:8080";
@@ -19,4 +19,8 @@
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="SatelliteProvider.Tests" />
</ItemGroup>
</Project>
@@ -0,0 +1,56 @@
using FluentAssertions;
using SatelliteProvider.IntegrationTests;
namespace SatelliteProvider.Tests;
public class PerfBootstrapPt10Tests
{
[Fact]
public void Percentile_MatchesHarnessFormula_AZ1124_AC2()
{
var values = new long[] { 10, 20, 30, 40, 50, 60, 70, 80, 90, 100 };
PerfBootstrap.Percentile(values, 50).Should().Be(50);
PerfBootstrap.Percentile(values, 95).Should().Be(100);
}
[Fact]
public void PerfScript_DoesNotInlineJwtMint_AZ1124_AC6()
{
var path = LocateRepoFile(Path.Combine("scripts", "run-performance-tests.sh"));
path.Should().NotBeNull();
var content = File.ReadAllText(path!);
content.Should().Contain("--run-pt10");
content.Should().NotContain("JwtSecurityToken");
content.Should().NotContain("new JwtSecurityToken(");
}
[Fact]
public void Program_DispatchesRunPt10Subcommand_AZ1124_AC1()
{
var path = LocateRepoFile(Path.Combine("SatelliteProvider.IntegrationTests", "Program.cs"));
path.Should().NotBeNull();
var content = File.ReadAllText(path!);
content.Should().Contain("--run-pt10");
content.Should().Contain("RunPt10Async");
}
private static string? LocateRepoFile(string relativePath)
{
var dir = new DirectoryInfo(Directory.GetCurrentDirectory());
while (dir is not null)
{
var candidate = Path.Combine(dir.FullName, relativePath);
if (File.Exists(candidate))
{
return candidate;
}
dir = dir.Parent;
}
return null;
}
}
@@ -44,6 +44,7 @@
<ProjectReference Include="..\SatelliteProvider.Common\SatelliteProvider.Common.csproj" />
<ProjectReference Include="..\SatelliteProvider.DataAccess\SatelliteProvider.DataAccess.csproj" />
<ProjectReference Include="..\SatelliteProvider.TestSupport\SatelliteProvider.TestSupport.csproj" />
<ProjectReference Include="..\SatelliteProvider.IntegrationTests\SatelliteProvider.IntegrationTests.csproj" />
</ItemGroup>
</Project>
@@ -73,3 +73,14 @@
**Pass criterion**: `p95(durations[20]) ≤ 1000ms` (samples sorted ascending; p95 = `sorted[18]` over 20 samples per the test). Cycle 6 measured: `min=13ms, median=19ms, p95=66ms, max=117ms` — well under budget.
**Source**: AZ-505 AC-4 — `_docs/02_tasks/done/AZ-505_tile_inventory_http2_leaflet_index.md` § Acceptance Criteria. Resolves the AZ-503 AC-9 perf NFR deferral (budget relaxed from 500 ms to 1000 ms during AZ-505 scoping — see AZ-505 Risk 1 in the task spec).
**Note (promotion to perf harness)**: The in-test gate runs against the same Docker compose stack as the rest of the integration suite, so the perf budget is verified on the same hardware as functional tests. If we ever need to tighten the budget (e.g., to 500 ms for production-equivalent hardware) or add cross-commit baseline comparison like PT-07, promote this to `scripts/run-performance-tests.sh § PT-09` with a `PERF_INVENTORY_BATCH_SIZE` env variable controlling the row count and a separate cold/warm distinction.
## PT-10: gRPC DeliverRouteTiles Stream Latency
**Status**: **Implemented (AZ-1124).** Runner scenario: `scripts/run-performance-tests.sh` § "PT-10".
**Trigger**: `RouteTileDelivery.DeliverRouteTiles` server-streaming RPC over gRPC (TLS + JWT metadata) against a live API. Invoked via `SatelliteProvider.IntegrationTests --run-pt10`, which reuses `GrpcTestHelpers`, `PerfBootstrap` JWT mint, and the standard 2-waypoint / 500 m / zoom-18 fixture.
**Load**: `PERF_REPEAT_COUNT` sequential cold iterations (default 20) plus one slow-consumer iteration with `PERF_PT10_SLOW_MS` delay between stream events (default 50 ms).
**Expected**: First `Batch` event arrives within the cold-path GM budget; full stream completes with `DeliveryComplete`; slow consumer completes without `DeliveryError`.
**Pass criterion**: `p95(first_batch_ms) ≤ 30000`, `p95(total_stream_ms) ≤ 120000`, and slow-consumer sub-check passes (no fixed ms threshold). Threshold failures exit non-zero; autodev Step 15 may offer override.
**Source**: Cycle 911 retro carry-over — gRPC functional coverage (AZ-1074/1075 / BT-32) without streaming NFR evidence; closes the Unverified gRPC perf gap.
**Note**: Cold iterations may include Google Maps download on the first pass over a fresh compose volume; record actuals in `_docs/06_metrics/perf_<date>.md`. Host-side runs require `API_URL=https://localhost:18980` (or perf overlay port) and trust `./certs/api.crt` via `PROJECT_ROOT` (same as REST perf probes).
@@ -188,6 +188,7 @@
| AZ-487 Security — `RequireSignedTokens`, `RequireExpirationTime`, `ClockSkew = 30 s`, secret ≥ 32 bytes, `iss` + `aud` validated (extended by AZ-494) | AZ-487 + AZ-494 task specs § Non-Functional Requirements + Constraints | `AuthenticationServiceCollectionExtensionsTests` (unit) + SEC-05..SEC-09 + AZ-494 AC-1/AC-2 wrong-iss/aud (integration) | ✓ |
| AZ-487 Reliability — Fail-fast on missing / short `JWT_SECRET` at startup (extended by AZ-494 to iss + aud) | AZ-487 + AZ-494 task specs § Non-Functional Requirements | SEC-08 (behavioral) + unit `AddSatelliteJwt_ThrowsOnMissingSecret` + `_ThrowsOnMissingIssuer` + `_ThrowsOnMissingAudience` | ✓ |
| AZ-488 Performance — Per-item gate cost < 50 ms; p95 batch-of-10 < 2 s | AZ-488 task spec § Non-Functional Requirements | PT-08 (Implemented in AZ-492 — 20-batch distribution, batch p95 gated at 2000 ms; per-item gate cost reported as derived proxy `batch_p95 / batch_size`. True per-call `UavTileQualityGate.Validate` timing requires server-side instrumentation — follow-up). | ✓ (batch p95) / ◐ (per-item proxy only) |
| Cycle 9 — gRPC `DeliverRouteTiles` stream latency / backpressure (NFR) | Cycle 9 retro Action #2; AZ-1124 task spec § Non-Functional Requirements | PT-10 (Implemented in AZ-1124 — `scripts/run-performance-tests.sh` § PT-10 + `SatelliteProvider.IntegrationTests --run-pt10`; first_batch + total_stream p50/p95 + slow-consumer smoke) | ✓ |
| AZ-488 Reliability — File-first then DB row; per-item failures never fail the batch envelope (except 400/401/403) | AZ-488 task spec § Non-Functional Requirements | BT-14 (mixed-batch shows per-item isolation); `UavTileUploadHandlerTests.*PersistAsync*` (unit); reject reason `STORAGE_FAILURE` defined in contract for the orphan-row recovery path | ✓ |
| AZ-488 Compatibility — Replaces 501 stub; coexists with AZ-484 `tile-storage` v1.0.0 contract on the write side | AZ-488 task spec § Non-Functional Requirements + Contract | `StubAndErrorContractTests` updated to drop the stub-501 expectation; BT-15 + BT-16 validate the AZ-484 invariants under live UAV writes | ✓ |
| AZ-488 Security — Reject details never leak server internals; integer-only file-path construction | AZ-488 task spec § Non-Functional Requirements + Risk 2 | SEC-11 (blackbox); `UavTileFilePathTests` (unit) | ✓ |
@@ -203,7 +204,7 @@
|----------|-------------|-------------|---------------------|
| Blackbox (positive) | 12 | 19/22 | — |
| Blackbox (negative) | 5 | — | — |
| Performance | 8 | 4 | 1 |
| Performance | 9 | 5 | 1 |
| Resilience | 6 | 4 | 3 |
| Security | 14 | 9 (AZ-487 AC-1..AC-7, AZ-488 AC-6, leak-hygiene NFR) + 3 (AZ-1113 AC-1..AC-3) | 1 (AZ-487 supersedes "No authentication") |
| Resource Limits | 7 | 5 | 4 |
@@ -282,6 +283,12 @@
| AZ-1123 AC-1 | `containerization.md` documents 5433 conflict + `docker-compose.perf.yml` command | doc-state AC; verified at Step 13 (`deployment/containerization.md` §Compose overlays) | ✓ |
| AZ-1123 AC-2 | `environment.md` names perf overlay and links to containerization playbook | doc-state AC; verified at Step 13 (`tests/environment.md`) | ✓ |
| AZ-1123 AC-3 | Integration (`docker-compose.tests.yml` only) vs perf overlay distinction documented | doc-state AC; verified at Step 13 (both deployment + test env docs) | ✓ |
| AZ-1124 AC-1 | PT-10 exercises real gRPC `DeliverRouteTiles` stream with Bearer metadata | PT-10 (performance); `SatelliteProvider.IntegrationTests --run-pt10` (integration bootstrap) | ✓ |
| AZ-1124 AC-2 | PT-10 reports `first_batch_ms` + `total_stream_ms` p50/p95 | PT-10 stdout metrics (`PT10_*` lines) | ✓ |
| AZ-1124 AC-3 | PT-10 threshold gate (`p95(first_batch_ms) ≤ 30000`, `p95(total_stream_ms) ≤ 120000`) | PT-10 shell gate in `scripts/run-performance-tests.sh` | ◐ gate at Step 15 |
| AZ-1124 AC-4 | PT-10 slow-consumer smoke completes without `DeliveryError` | PT-10 `PT10_SLOW_CONSUMER=PASS` sub-check | ✓ |
| AZ-1124 AC-5 | PT-10 documented in `performance-tests.md`; gRPC stream perf no longer Unverified | doc-state AC; verified at Step 13 | ◐ doc-verified at Step 13 |
| AZ-1124 AC-6 | PT-10 reuses `PerfBootstrap` / `JwtTokenFactory` / `GrpcTestHelpers` — no third JWT mint in shell | `PerfBootstrapPt10Tests` (unit — static review) | ✓ |
**Coverage shape notes (Cycle 11 — AZ-1123 perf compose documentation):**
- Documentation-only cycle — no new runtime tests, blackbox scenarios, perf thresholds, or security findings. Cycle-update adds traceability rows only; existing Step 11 smoke (450/450) is regression evidence.
+7
View File
@@ -262,6 +262,13 @@ Step 9 cycle 8b: folded into cycle 8 as step 1 (AZ-812). Section retained in dep
Step 9 cycle 9: 2 tasks created (AZ-1074 = 5 pts, AZ-1075 = 3 pts) — total 8 pts. gRPC TileStream for route-based progressive tile delivery.
Step 9 cycle 10: 1 task created (AZ-1113 = 2 pts) — REST 400 error message sanitization (F-AZ795-1/2, F-AZ810-1). Child of AZ-795.
Step 9 cycle 11: 1 task created (AZ-1123 = 1 pt) — document `docker-compose.perf.yml` host-port conflict playbook (cycle 10 retro action).
Step 9 cycle 12: 1 task created (AZ-1124 = 3 pts) — PT-10 gRPC `DeliverRouteTiles` stream perf scenario (cycle 911 retro carry-over).
### Step 9 cycle 12 (PT-10 gRPC stream perf — AZ-1124)
| Task | Depends On | Points | Status |
|------|-----------|--------|--------|
| AZ-1124 PT-10 gRPC stream perf scenario | AZ-1074, AZ-1075, AZ-492 | 3 | Todo |
## Coverage Verification
@@ -0,0 +1,142 @@
# PT-10: gRPC DeliverRouteTiles stream performance scenario
**Task**: AZ-1124_pt10_grpc_stream_perf
**Name**: PT-10 gRPC stream perf scenario
**Description**: Add a runnable PT-10 performance scenario that measures latency of the existing `DeliverRouteTiles` server-streaming RPC against a live API.
**Complexity**: 3 points
**Dependencies**: AZ-1074, AZ-1075, AZ-492
**Component**: Test infrastructure (`scripts/run-performance-tests.sh`, `SatelliteProvider.IntegrationTests`) + perf NFR coverage (`_docs/02_document/tests/performance-tests.md`)
**Tracker**: AZ-1124
**Epic**: AZ-115
### Document Dependencies
- `_docs/02_document/contracts/c11_tilemanager/tile_provision_grpc.md` (consumer — wire contract for `DeliverRouteTiles`)
- `_docs/02_document/tests/blackbox-tests.md` § BT-32 (functional baseline; PT-10 adds NFR evidence)
### ADR Compliance
> Implements ADR-013: gRPC `RouteTileDelivery` transport alongside REST for progressive tile delivery.
## Problem
Cycle 9 shipped `RouteTileDelivery.DeliverRouteTiles` (AZ-1074) with functional integration coverage (AZ-1075 / BT-32). The performance gate still runs PT-01..PT-08 (REST-only). Step 15 can pass while the gRPC streaming surface remains **Unverified** for latency — a gap called out in cycle 911 retrospectives and LESSONS.md (`[testing]` entry 2026-06-25).
Operators and autodev Step 15 need measurable evidence for:
- **Time-to-first tile batch** on a cold path (may include Google Maps download)
- **Total stream duration** until `DeliveryComplete`
- **Slow-consumer smoke** — stream completes without corruption when the client deliberately delays between events (backpressure sanity check per AZ-1074 AC-4)
## Outcome
- `scripts/run-performance-tests.sh` runs PT-10 against the **real** gRPC `DeliverRouteTiles` endpoint (TLS + JWT metadata), not a mock.
- PT-10 reports `first_batch_ms`, `total_stream_ms`, and optional slow-consumer pass/fail over `PERF_REPEAT_COUNT` iterations (default 20).
- `_docs/02_document/tests/performance-tests.md` documents PT-10 triggers, thresholds, and pass criteria.
- `_docs/02_document/tests/traceability-matrix.md` marks gRPC stream perf as covered (no longer Unverified).
- gRPC perf gap from cycle 911 retros is closed.
## Scope
### Included
- Add `SatelliteProvider.IntegrationTests` perf bootstrap subcommand (e.g. `--run-pt10`) invoked from `scripts/run-performance-tests.sh`, following the AZ-492 pattern (`--mint-only`, `--gen-uav-fixture`). The subcommand:
- Mints or accepts JWT via existing `PerfBootstrap` / `JwtTokenFactory` surface
- Opens a gRPC channel to `API_URL` (same TLS trust as REST perf probes)
- Calls `DeliverRouteTiles` with the standard 2-waypoint / 500m / zoom-18 fixture (`GrpcTestHelpers.BuildValidRequest` coordinates)
- Records wall-clock **first `RouteTileEvent` with `Batch` payload** and **stream close** (`DeliveryComplete` or `DeliveryError`)
- Supports `PERF_REPEAT_COUNT` cold iterations; prints p50/p95 summary to stdout for the shell script to gate
- Optional slow-consumer mode (`PERF_PT10_SLOW_MS` delay between stream events, default 50) on a single iteration — asserts stream completes with ≥1 tile and no `DeliveryError`
- Add PT-10 section to `scripts/run-performance-tests.sh` with non-zero exit on harness failure (same contract as PT-07/PT-08).
- Add PT-10 spec block to `_docs/02_document/tests/performance-tests.md` with documented thresholds:
- Cold `p95(first_batch_ms) ≤ 30000` (aligns with PT-01 cold tile budget — includes GM round-trip)
- Cold `p95(total_stream_ms) ≤ 120000` (2-point 500m corridor at zoom 18 on dev hardware)
- Slow-consumer sub-check: completes without error (no fixed ms threshold)
- Update `traceability-matrix.md` — gRPC streaming NFR row references PT-10.
- Update script header comment from "PT-01..PT-08" to include PT-10.
### Excluded
- Production code changes to `RouteTileDeliveryGrpcService`, orchestrator, or proto (endpoint already exists).
- New gRPC RPCs or contract version bumps.
- Load testing with concurrent streams (k6 / multiple clients) — single-client sequential repeats only.
- Promoting PT-09 inventory inline test into the shell harness (separate follow-up).
- Setting production SLOs — thresholds are dev-harness budgets; Step 15 retains its A/B/C gate on threshold failures.
## Acceptance Criteria
**AC-1: PT-10 exercises real gRPC stream**
Given a running API with TLS and valid `JWT_SECRET`
When `scripts/run-performance-tests.sh` runs PT-10
Then the probe calls `RouteTileDelivery.DeliverRouteTiles` over gRPC with `authorization: Bearer` metadata, AND receives at least one `Batch` event before `DeliveryComplete` on every successful iteration.
**AC-2: Latency metrics reported**
Given `PERF_REPEAT_COUNT` cold iterations (default 20)
When PT-10 completes
Then the harness prints `first_batch_ms` and `total_stream_ms` per iteration plus aggregate p50/p95 for both metrics.
**AC-3: Threshold gate**
Given the documented dev-hardware budgets in `performance-tests.md`
When PT-10 p95 values are compared
Then `p95(first_batch_ms) ≤ 30000` AND `p95(total_stream_ms) ≤ 120000`, OR the script exits non-zero with an explicit threshold failure message (Step 15 may still offer override).
**AC-4: Slow-consumer smoke**
Given `PERF_PT10_SLOW_MS` > 0 (default 50)
When PT-10 runs the slow-consumer sub-check once per script invocation
Then the stream completes with `DeliveryComplete`, at least one tile in collected batches, AND no `DeliveryError`.
**AC-5: Spec and traceability updated**
Given the post-task repository state
When `performance-tests.md` and `traceability-matrix.md` are read
Then PT-10 is documented as **Implemented** with scenario ID PT-10, AND the gRPC stream perf row is no longer `Unverified`.
**AC-6: No duplicated JWT / gRPC bootstrap**
Given existing `PerfBootstrap`, `GrpcTestHelpers`, and `JwtTokenFactory`
When PT-10 mints tokens and opens channels
Then it reuses those surfaces — no third JWT mint implementation and no parallel gRPC client factory in the shell script.
## Non-Functional Requirements
**Performance**
- Thresholds above are dev-compose budgets on 8-core x86 baseline; document revision path if hardware changes.
**Reliability**
- Script exits non-zero if gRPC channel cannot be established, JWT is missing, or zero successful iterations complete.
- Fail loudly with RPC status code and detail on `DeliveryError` or non-OK `RpcException`.
**Compatibility**
- Bash 4+; gRPC probe runs via `dotnet` IntegrationTests DLL (already built for perf bootstrap).
- Works with `docker-compose.perf.yml` overlay and `API_URL=https://localhost:18980` default.
## Unit Tests
| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-6 | Grep / static review | No new `JwtSecurityToken` constructors in perf script; PT-10 logic lives in IntegrationTests |
## Blackbox Tests
| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|-------------|-------------------|----------------|
| AC-1..AC-4 | API + DB running via compose; valid JWT | Run full `scripts/run-performance-tests.sh` including PT-10 | Real gRPC stream; metrics printed; thresholds evaluated | PT-10 NFR |
| AC-1 | Same stack | Run IntegrationTests `--run-pt10` in isolation | Exit 0; JSON or tabular metrics on stdout | — |
## Constraints
- Must not regress PT-01..PT-08 or `scripts/run-tests.sh`.
- Must not commit minted tokens or disable TLS verification in tracked files.
- Threshold failures are reported by the script; autodev Step 15 handles operator override — this task does not weaken gates silently.
## Risks & Mitigation
**Risk 1: Cold-path variance from Google Maps**
- *Risk*: First-batch p95 spikes on network blips look like regressions.
- *Mitigation*: Document that PT-10 cold path includes GM download; Step 15 override path remains available; record actuals in `_docs/06_metrics/perf_<date>.md`.
**Risk 2: TLS / HTTP2 setup differs from integration tests**
- *Risk*: Perf script `API_URL` may not match in-compose `https://api:8080` used by integration tests.
- *Mitigation*: Reuse `GrpcTestHelpers.CreateChannel` cert handling; document required `API_URL` and `certs/api.crt` in perf script header (same as REST perf).
**Risk 3: Threshold too tight on laptop hardware**
- *Risk*: p95 gates fail on underpowered dev machines.
- *Mitigation*: Thresholds match PT-01/PT-03 family (generous dev budgets); tune in `_docs/06_metrics` after first green run if needed.
@@ -0,0 +1,30 @@
# Batch Report
**Batch**: 1
**Tasks**: AZ-1124_pt10_grpc_stream_perf
**Date**: 2026-06-26
## Task Results
| Task | Status | Files Modified | Tests | AC Coverage | Issues |
|------|--------|---------------|-------|-------------|--------|
| AZ-1124_pt10_grpc_stream_perf | Done | 10 files | 3/3 pass | 6/6 ACs covered | None |
## AC Test Coverage: All covered
| AC | Evidence |
|----|----------|
| AC-1 | `--run-pt10` + shell PT-10 section; `Program_DispatchesRunPt10Subcommand` |
| AC-2 | `Percentile_MatchesHarnessFormula`; PT10_* stdout lines |
| AC-3 | `check_threshold` in `run-performance-tests.sh` (Step 15 gate) |
| AC-4 | `PT10_SLOW_CONSUMER` in probe + shell gate |
| AC-5 | `performance-tests.md` PT-10 + traceability rows |
| AC-6 | `PerfScript_DoesNotInlineJwtMint` |
## Code Review Verdict: PASS
## Auto-Fix Attempts: 0
## Stuck Agents: None
## Next Batch: All tasks complete
@@ -0,0 +1,13 @@
# Product Implementation Completeness — Cycle 12
## Per-task classification
| Task | Classification | Evidence |
|------|----------------|----------|
| AZ-1124 | PASS | `PerfBootstrap.RunPt10Async`, `Program.cs --run-pt10`, `scripts/run-performance-tests.sh` PT-10 section, `performance-tests.md`, traceability rows |
## System Pipeline Audit
No new end-to-end production pipelines introduced. PT-10 measures the existing AZ-1074 gRPC surface only.
**Gate**: PASS — proceed to Step 11 (Run Tests).
@@ -0,0 +1,19 @@
# Implementation Report — PT-10 gRPC Stream Perf (Cycle 12)
**Cycle**: 12
**Tasks**: AZ-1124 (3 SP, 1 batch)
**Feature slug**: pt10_grpc_stream_perf
## Summary
Added PT-10 performance scenario for `DeliverRouteTiles` gRPC streaming: IntegrationTests `--run-pt10` bootstrap, shell harness section with p95 thresholds, and spec/traceability updates. Closes cycle 911 retro carry-over for unverified gRPC stream NFR coverage.
## Batches
| Batch | Tasks | Verdict |
|-------|-------|---------|
| 1 | AZ-1124 | PASS |
## Test handoff
Full suite gate deferred to autodev Step 11 (`test-run` skill). Focused unit tests: `PerfBootstrapPt10Tests` (3/3 pass).
+5 -5
View File
@@ -2,12 +2,12 @@
## Current Step
flow: existing-code
step: 9
name: New Task
status: not_started
step: 10
name: Implement
status: in_progress
sub_step:
phase: 0
name: awaiting-invocation
phase: 1
name: parse
detail: ""
retry_count: 0
cycle: 12
+36 -1
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
# Satellite Provider Performance Tests
#
# Runs PT-01..PT-08 against a live API. All probes carry a Bearer token minted
# Runs PT-01..PT-10 against a live API. All probes carry a Bearer token minted
# from JWT_SECRET (AZ-487 required RequireAuthorization on every endpoint;
# without the header every probe returns 401).
#
@@ -20,6 +20,7 @@ PROJECT_ROOT="$(dirname "$SCRIPT_DIR")"
API_URL="${API_URL:-https://localhost:18980}"
PERF_REPEAT_COUNT="${PERF_REPEAT_COUNT:-20}"
PERF_UAV_BATCH_SIZE="${PERF_UAV_BATCH_SIZE:-10}"
PERF_PT10_SLOW_MS="${PERF_PT10_SLOW_MS:-50}"
# AZ-505 dev TLS: the dev compose stack now binds Kestrel on https://+:8080 with
# a self-signed cert (./certs/api.crt) so ALPN can negotiate HTTP/2. Every curl
@@ -464,6 +465,40 @@ else
fi
fi
# --- PT-10: gRPC DeliverRouteTiles stream latency ---
echo ""
echo "PT-10: gRPC DeliverRouteTiles stream latency (N=${PERF_REPEAT_COUNT}, slow_ms=${PERF_PT10_SLOW_MS})"
export PROJECT_ROOT
PT10_STDERR="$PERF_TMP_DIR/pt10_stderr.log"
if ! PT10_LINES=$(dotnet "$PERF_DLL" --run-pt10 2>"$PT10_STDERR"); then
echo " ✗ PT-10: --run-pt10 failed"
cat "$PT10_STDERR" >&2 || true
FAIL=$((FAIL + 1))
else
cat "$PT10_STDERR" >&2 || true
eval "$(printf '%s\n' "$PT10_LINES" | grep '^PT10_')"
if [[ -z "${PT10_FIRST_BATCH_P95:-}" || -z "${PT10_TOTAL_STREAM_P95:-}" ]]; then
echo " ✗ PT-10: missing metric lines on stdout"
echo "$PT10_LINES"
FAIL=$((FAIL + 1))
else
echo " first_batch: p50=${PT10_FIRST_BATCH_P50}ms p95=${PT10_FIRST_BATCH_P95}ms (N=${PT10_ITERATIONS_OK:-0})"
echo " total_stream: p50=${PT10_TOTAL_STREAM_P50}ms p95=${PT10_TOTAL_STREAM_P95}ms (N=${PT10_ITERATIONS_OK:-0})"
check_threshold "PT-10 first_batch p95" "$PT10_FIRST_BATCH_P95" 30000
check_threshold "PT-10 total_stream p95" "$PT10_TOTAL_STREAM_P95" 120000
if [[ "${PT10_SLOW_CONSUMER:-FAIL}" == "PASS" ]]; then
echo " ✓ PT-10 slow-consumer: PASS (delay=${PERF_PT10_SLOW_MS}ms)"
PASS=$((PASS + 1))
else
echo " ✗ PT-10 slow-consumer: FAIL"
FAIL=$((FAIL + 1))
fi
fi
fi
# --- Summary ---
echo ""