Skip to content

Commit c4f2d23

Browse files
johnsimonsrbev
andauthored
Generate audit and saga load against a ServiceControl.Audit instance (#5969)
* Add direct audit-queue writer and saga episode generation to the testing tool Extends the testing tool to drive ServiceControl.Audit ingestion, not just error ingestion. The new writer sends audit envelopes straight to the audit queue, including whole saga episodes (New/Updated/Completed messages plus their snapshots) linked via InvokedSagas/SagaStateChange headers so the audit instance can resolve saga history and drill-through. Also wires audit instances in the AppHost to the same SQL Server/PostgreSQL backend as the primary via EF persistence, with ingestion tuning, OTel export and custom-check reporting to the primary, and extends the Grafana dashboard and docs to cover the new audit load paths. * Bump Aspire to 13.6.1 and default audit instances to 1 Updates Aspire.Hosting.* packages and the AppHost SDK to 13.6.1, enables the CLI bundle, and adds appsettings.json for logging configuration. Also changes the default audit-instances option from 0 to 1 so audit load is exercised out of the box. * Fix audit instance creation & remove warning * Default audit to 1 instance, change dashboard calc * Pin pacakge with CVE * retry folder delete to avoid locking problem on windows ci builds * Add cancellation support * Update test --------- Co-authored-by: Rhys Bevilaqua <rhys.bevilaqua@particular.net>
1 parent dd100b9 commit c4f2d23

27 files changed

Lines changed: 2052 additions & 283 deletions

‎src/ServiceControlInstaller.Engine.UnitTests/Validation/PackagedRavenDBTests.cs‎

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -77,10 +77,7 @@ await store.Maintenance.SendAsync(new PutIndexesOperation(new IndexDefinition
7777
finally
7878
{
7979
await StopServer();
80-
if (Directory.Exists(root))
81-
{
82-
FileUtils.DeleteDirectory(root, recursive: true, contentsOnly: false);
83-
}
80+
await TryDeleteDirectory(root, CancellationToken.None);
8481
}
8582

8683
async Task StartServer()
@@ -143,9 +140,9 @@ async Task StopServer()
143140
server.Kill(entireProcessTree: true);
144141
}
145142

146-
await server.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(15));
147-
TestContext.Out.WriteLine(await standardOutput.WaitAsync(TimeSpan.FromSeconds(15)));
148-
TestContext.Out.WriteLine(await standardError.WaitAsync(TimeSpan.FromSeconds(15)));
143+
await server.WaitForExitAsync(CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(15), cancellationToken);
144+
await TestContext.Out.WriteLineAsync(await standardOutput.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken));
145+
await TestContext.Out.WriteLineAsync(await standardError.WaitAsync(TimeSpan.FromSeconds(15), cancellationToken));
149146
}
150147
finally
151148
{
@@ -155,6 +152,30 @@ async Task StopServer()
155152
}
156153
}
157154

155+
// Cleanup of the temp folder must not fail the test; Directory.Delete keeps the path stable across retries.
156+
static async Task TryDeleteDirectory(string root, CancellationToken cancellationToken, int attempts = 5, TimeSpan? delay = null)
157+
{
158+
var retryDelay = delay ?? TimeSpan.FromMilliseconds(500);
159+
for (var attempt = 1; Directory.Exists(root); attempt++)
160+
{
161+
try
162+
{
163+
Directory.Delete(root, recursive: true);
164+
return;
165+
}
166+
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
167+
{
168+
if (attempt == attempts)
169+
{
170+
await TestContext.Out.WriteLineAsync($"WARNING: Could not delete '{root}' after {attempts} attempts: {ex}");
171+
return;
172+
}
173+
174+
await Task.Delay(retryDelay * attempt, cancellationToken);
175+
}
176+
}
177+
}
178+
158179
static async Task AssertDocumentAndIndex(IDocumentStore store, CancellationToken cancellationToken)
159180
{
160181
using var session = store.OpenAsyncSession();

‎tools/testing-tool/README.md‎

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A stateless, horizontally-scalable .NET 10 service that generates error load and
44
scenarios against a test ServiceControl instance to validate its error-ingestion performance, with
55
OpenTelemetry observability and a simple web UI for manual scenario control.
66

7-
The tool targets the error ingestion path only — audit testing is out of scope.
7+
The tool drives both ingestion paths: failed messages into the primary ServiceControl instance, and audited messages and saga snapshots into a ServiceControl.Audit instance.
88

99
## What it does
1010

@@ -73,6 +73,28 @@ failed message and groups it like the equivalent handler failure. Control via:
7373
- `POST /api/bypass/stop`
7474
- `GET /api/bypass/status`
7575

76+
### Direct audit-queue writer
77+
78+
The audit-queue counterpart of the bypass writer. It writes audit envelopes straight to the ServiceControl.Audit instance's audit queue, so audit ingestion can be loaded without any endpoint ever processing a message. Control via:
79+
80+
- `POST /api/audit-bypass/start` — `{ "rate": 100, "parallelism": 4, "sagaSnapshotRatio": 0.2, "durationSeconds": 60 }`
81+
- `POST /api/audit-bypass/stop`
82+
- `GET /api/audit-bypass/status`
83+
84+
Most claims emit one processed-message envelope carrying the headers a real endpoint would have stamped: `NServiceBus.ProcessingEndpoint`, the host ids, and `TimeSent`, `ProcessingStarted` and `ProcessingEnded` spread so delivery, processing and critical times are not zero. They rotate across a pool of simulated endpoints, message types and conversations.
85+
86+
A share of claims instead emit a whole **saga episode**: the three messages that drove one saga through New, Updated and Completed, each followed by the snapshot it produced. The episode is written by a single worker so the phases reach the queue in order, and the pieces are linked the way a real deployment links them:
87+
88+
- each driving message carries `NServiceBus.InvokedSagas` (`SagaType:SagaId`) and `ServiceControl.SagaStateChange` (`SagaId:New|Updated|Completed`), which is the only thing that lets the audit instance attach saga information to an audited message
89+
- each snapshot's `Initiator.InitiatingMessageId` is the id of the message directly before it, so drill-through from the saga view resolves to a real audited message
90+
- the snapshot body is written in exact CLR casing, because ServiceControl deserializes it with a source-generated, case-sensitive context, and its `EnclosedMessageTypes` header is the bare unqualified type name, which is what the audit instance compares against to route a message to its saga branch
91+
92+
`sagaSnapshotRatio` is saga snapshots per processed message; the default `0.2` emits two saga snapshots for every ten processed messages. `0` disables saga episodes.
93+
94+
Audit ingestion needs a ServiceControl.Audit instance on SQL Server or PostgreSQL, so pair `--audit-instances` with `--persistence:PostgreSql` or `--persistence:SqlServer`. Under `--persistence:RavenDb` the audit instance runs on RavenDB and the EF path is not exercised.
95+
96+
The testing tool's own endpoint also audits its successfully handled load messages to the same queue, which is what makes its installers create the queue before the writer first sends into it.
97+
7698
### Release-test scenario presets
7799

78100
The tool ships with presets mapped from [docs/testing-scenarios.md](/docs/testing-scenarios.md) so
@@ -162,11 +184,11 @@ separator) or `--name:value` (colon separator):
162184

163185
| Flag | Default | Values | Description |
164186
|---|---|---|---|
165-
| `--persistence` | `RavenDb` | `RavenDb`, `SqlServer`, `PostgreSql` | Persistence backend for the ServiceControl error instance |
187+
| `--persistence` | `RavenDb` | `RavenDb`, `SqlServer`, `PostgreSql` | Persistence backend for the ServiceControl error instance and for the audit instances |
166188
| `--transport` | `RabbitMq` | `RabbitMq`, `SqlServer` | Transport shared by ServiceControl and the testing tool. `SqlServer` reuses the persistence SQL Server container with a separate `Transport` database |
167189
| `--tag` | *(none — uses the current build's image)* | any image tag, e.g. `pr-1234` or `6.3.1` | Override the ServiceControl container image tag (useful for testing PR-based prereleases) |
168190
| `--error-ingestion-scale-unit` | `0` | non-negative integer | Number of additional error-ingestion-only scale-out instances to spin up alongside the primary error instance (each runs with `--error-ingestion-only`) |
169-
| `--audit-instances` | `0` | non-negative integer | Number of ServiceControl audit instances to add |
191+
| `--audit-instances` | `1` | non-negative integer | Number of ServiceControl.Audit instances to add. They take the same `--persistence` backend, report custom checks to the primary and export OTel to the collector |
170192

171193
The Aspire dashboard provides allocated ports for each service. The testing tool automatically
172194
connects to ServiceControl via the platform's transport and REST API URL, and sends its OTLP
@@ -242,7 +264,8 @@ All configuration is via environment variables (no files, no database). Settings
242264
| `TestingTool__CustomCheckInterval` | `00:00:30` | Default interval for the custom-check-failures job |
243265
| `TestingTool__CustomCheckHost` | `ServiceControl` | `Host` field on injected custom-check reports |
244266
| `TestingTool__CustomCheckFailureProbability` | `0.4` | Probability (0–1) a given check is reported failed each cycle |
245-
| `TestingTool__AuditQueueName` | `audit` | NServiceBus audit queue for processed messages |
267+
| `TestingTool__AuditQueueName` | `audit` | NServiceBus audit queue the ServiceControl.Audit instance drains |
268+
| `TestingTool__AuditSagaSnapshotRatio` | `0.2` | Default saga snapshots per processed message for the audit-queue writer |
246269
| `TestingTool__MonitoringQueueName` | `Particular.Monitoring` | ServiceControl monitoring instance queue that endpoint metrics are sent to |
247270
| `TestingTool__ErrorQueueName` | `error` | NServiceBus error queue (ServiceControl monitors this) |
248271
| `TestingTool__AutoStartBackgroundNoise` | `false` | Auto-start the background-noise scenario on startup |

‎tools/testing-tool/TestingTool.AppHost/AppHost.cs‎

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33

44
var options = CliOptions.Parse(args);
55
var persistenceType = options.GetValue("persistence", PersistenceType.RavenDb);
6+
var imageTag = options.GetValueOrDefault("tag");
67
Console.WriteLine($"Using persistence type: {persistenceType}");
78

89
var builder = DistributedApplication.CreateBuilder(args);
@@ -51,10 +52,25 @@
5152

5253
platform.AddServicePulse("pulse", primaryErrorInstance, platform.AddServiceControlMonitoringInstance("monitoring"));
5354

54-
for (int i = 0; i < options.GetValue("audit-instances", 0); i++)
55+
// EF persistence lives in the audit instance itself, so audit load needs a real audit container.
56+
// The platform persistence argument stays RavenDB because the Aspire package only knows about
57+
// platform persistences; the SQL wiring is layered on with WithPersistenceType.
58+
var auditInstanceCount = options.GetValue("audit-instances", 0);
59+
60+
for (int i = 0; i < auditInstanceCount; i++)
5561
{
62+
//note: these audit instances are currently independant and are not scale out nodes like the error ingestion ones.
5663
platform.AddServiceControlAuditInstance("audit" + i, primaryErrorInstance, raven)
57-
.WithEnvironment("INSTANCENAME", "Audit-" + i);
64+
.WithEnvironment("SERVICECONTROL_AUDIT_INSTANCENAME", "Audit-" + i)
65+
.WithEnvironment("OTEL_EXPORTER_OTLP_ENDPOINT", observability.Collector.GetEndpoint("otlp-grpc"))
66+
// Reports the audit ingestion and failed-import custom checks to the primary, which is the
67+
// only instance ServicePulse asks. Without it a failed audit import is invisible outside
68+
// the audit container's own log.
69+
.WithEnvironment("SERVICECONTROL_AUDIT_SERVICECONTROLQUEUEADDRESS", "Particular.ServiceControl")
70+
.WithAuditQueueName(i == 0 ? "audit" : "audit" + i)
71+
.WithRunMode(PlatformRunMode.SetupAndRun)
72+
.WithIngestionTuning()
73+
.WithPersistenceType(persistenceType);
5874
}
5975

6076
// --- Testing tool ---
@@ -69,6 +85,6 @@
6985
.WaitFor(observability.Collector);
7086

7187
// --- Optional: override ServiceControl image tag for prerelease testing ---
72-
builder.UseServiceControlImageTag(options.GetValueOrDefault("tag"));
88+
builder.UseServiceControlImageTag(imageTag);
7389

7490
builder.Build().Run();

‎tools/testing-tool/TestingTool.AppHost/HostBuilderExtensions.cs‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,52 @@ public static IResourceBuilder<ServiceControlErrorInstanceResource> WithPersiste
7070

7171

7272

73+
/// <summary>
74+
/// Points a ServiceControl.Audit instance at the same SQL Server or PostgreSQL container the
75+
/// primary uses. The keys are namespaced to the audit instance, and there is no body storage:
76+
/// the audit EF persisters store bodies inline in the audit row, capped by MaxBodySizeToStore.
77+
/// </summary>
78+
/// <remarks>
79+
/// The setting root namespace is "ServiceControl.Audit", and the settings reader rewrites both
80+
/// the dot and the slash to an underscore, so ServiceControl.Audit/Database/ConnectionString is
81+
/// SERVICECONTROL_AUDIT_DATABASE_CONNECTIONSTRING. The unprefixed names the audit container's
82+
/// Dockerfile bakes in (PersistenceType, AuditRetentionPeriod) are later in the lookup order,
83+
/// so the namespaced form wins.
84+
/// </remarks>
85+
public static IResourceBuilder<ServiceControlAuditInstanceResource> WithPersistenceType(
86+
this IResourceBuilder<ServiceControlAuditInstanceResource> audit, PersistenceType type)
87+
{
88+
if (type == PersistenceType.RavenDb)
89+
{
90+
return audit;
91+
}
92+
93+
var db = type switch
94+
{
95+
PersistenceType.SqlServer => audit.ApplicationBuilder.AddSqlServerPersistence("ServiceControl"),
96+
PersistenceType.PostgreSql => audit.ApplicationBuilder.AddPostgresPersistence("servicecontrol"),
97+
_ => throw new ArgumentOutOfRangeException(nameof(type), type, null)
98+
};
99+
100+
return audit
101+
.WaitFor(db)
102+
.WithEnvironment("SERVICECONTROL_AUDIT_PERSISTENCETYPE", PersistenceTypeName(type))
103+
.WithEnvironment("SERVICECONTROL_AUDIT_DATABASE_CONNECTIONSTRING", db);
104+
}
105+
106+
/// <summary>
107+
/// The audit equivalent of the ingestion tuning the primary carries. The setting names are
108+
/// AuditIngestion*, and they are read under the audit namespace, so the primary's
109+
/// SERVICECONTROL_ERRORINGESTION* values have no effect here.
110+
/// </summary>
111+
public static IResourceBuilder<ServiceControlAuditInstanceResource> WithIngestionTuning(
112+
this IResourceBuilder<ServiceControlAuditInstanceResource> audit) =>
113+
audit
114+
.WithEnvironment("SERVICECONTROL_AUDIT_MAXIMUMCONCURRENCYLEVEL", "100")
115+
.WithEnvironment("SERVICECONTROL_AUDIT_AUDITINGESTIONBATCHSIZE", "25")
116+
.WithEnvironment("SERVICECONTROL_AUDIT_AUDITINGESTIONMAXPARALLELWRITERS", "4")
117+
.WithEnvironment("SERVICECONTROL_AUDIT_AUDITINGESTIONBATCHTIMEOUT", "00:00:00.100");
118+
73119
public static IResourceBuilder<IResourceWithConnectionString> AddTransport(
74120
this IResourceBuilder<ParticularPlatformResource> platform, TransportType type)
75121
{

‎tools/testing-tool/TestingTool.AppHost/TestingTool.AppHost.csproj‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
<Nullable>enable</Nullable>
55
<ImplicitUsings>enable</ImplicitUsings>
66
<OutputType>Exe</OutputType>
7+
<AspireUseCliBundle>true</AspireUseCliBundle>
78
</PropertyGroup>
89

910
<ItemGroup>
@@ -18,4 +19,8 @@
1819
<ProjectReference Include="..\TestingTool\TestingTool.csproj" />
1920
</ItemGroup>
2021

22+
<ItemGroup Label="Direct references to transitive dependencies to avoid versions with CVE">
23+
<PackageReference Include="System.Security.Cryptography.Pkcs" Version="10.0.13" />
24+
</ItemGroup>
25+
2126
</Project>
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
{
2+
"Logging": {
3+
"LogLevel": {
4+
"Default": "Information",
5+
"Aspire": "Information",
6+
"Aspire.Hosting.Dcp": "Warning",
7+
"Microsoft": "Warning",
8+
"Particular": "Information"
9+
}
10+
}
11+
}

0 commit comments

Comments
 (0)