Skip to content

John/audit ef 9 - #5892

Draft
johnsimons wants to merge 20 commits into
masterfrom
john/audit_ef_9
Draft

johnsimons wants to merge 20 commits into
masterfrom
john/audit_ef_9

Conversation

@johnsimons

Copy link
Copy Markdown
Member

This design document outlines the architectural strategy and work plan for moving audit ingestion into the primary ServiceControl process for SQL Server and PostgreSQL persistence, including support for scaled-out ingestion-only workers.

johnsimons and others added 20 commits September 15, 2026 14:19
This design document outlines the architectural strategy and work plan for moving audit ingestion into the primary ServiceControl process for SQL Server and PostgreSQL persistence, including support for scaled-out ingestion-only workers.
First step of hosting audit ingestion in the primary instance. Contracts
only, no runtime behavior change: nothing resolves or calls any of this
yet, and no shipped persister advertises audit support.

- IIngestionUnitOfWork gains a nullable Audit child alongside Monitoring
  and Recoverability, so a batch can record audit messages and saga
  snapshots in the same transaction as known endpoints.
- New query contracts for the two entry points that are remote-only
  today: IAuditCountsDataStore and ISagaHistoryDataStore.
- IFailedAuditImportDataStore mirrors IFailedErrorImportDataStore, and
  FailedAuditImport.DeriveKey gives failed audit imports the same
  deterministic key with native-id fallback, so competing consumers do
  not write a row per delivery attempt.
- PersistenceManifest gains SupportsAuditIngestion. Absent means false,
  so RavenDB and every legacy manifest are unaffected. Both EF manifests
  declare it explicitly false until EF audit persistence lands.
- IMessagesViewDataStore documents the precedence, paging and counting
  rules a persister must honour once one local result set unions failed
  and audited messages.
- ServiceControl.Persistence.Tests.AuditCapable is a test-only persister
  that advertises audit support and delegates everything else to a real
  persister, so later pull requests can compose and start an
  audit-capable primary host before any shipped persister is one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adapts the standalone audit instance's receiving, enrichment, fault
handling, forwarding and metrics into the primary project, wired to
primary persistence and primary settings. The standalone audit
executable is behaviorally unchanged.

What the copy does differently:

- Endpoint detection writes through IMonitoringIngestionUnitOfWork
  instead of sending RegisterNewEndpoint through IMessageSession, the
  same way ErrorProcessor does. IMessageSession and the ICommand
  overload on the enricher context are gone, so the runtime composes in
  a host with no NServiceBus endpoint.
- Audit messages, saga snapshots and detected endpoints commit through
  one primary IIngestionUnitOfWork.
- Transport output is dispatched through the receiving infrastructure's
  own dispatcher, matching the error ingestion scale-out work. Retry
  acknowledgements stay transport operations, because the acknowledgement
  queue is named by whichever instance issued the retry.
- ITransportCustomization.CreateTransportInfrastructure takes a per
  receiver concurrency, so audit ingestion keeps its own concurrency in a
  host whose shared TransportSettings carries the primary endpoint's.
- The meter is renamed to Particular.ServiceControl and the primary
  gains the three OpenTelemetry packages plus OtlpEndpointUrl.
- The copied custom checks are renamed so they do not collide with the
  standalone audit instance reporting into this same primary.

AuditComponent registers all of it only when the configured persister
advertises SupportsAuditIngestion, so no shipped configuration changes
behavior. The composition tests run against the audit capable test
persister and assert what the host does and does not register, including
that disabling ingestion stops only the receiver.

Shared projects touched: ServiceControl.Transports gains an optional
argument with the existing behavior as its default, so the audit
executable's receiver is created exactly as before.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds --audit-ingestion-only to the command line and Help.txt, with the
three checks the plan calls for. The host itself is not composed yet, so
the command fails after its checks pass; no shipped persister advertises
audit support, so in practice it fails on the storage check.

- Storage that does not advertise SupportsAuditIngestion is rejected by
  name, driven by the manifest rather than by resolving optional
  services. RavenDB falls out of this without a special case.
- The two ingestion only modes cannot be combined. Each queue gets its
  own worker pool so they can be scaled independently.
- File system body storage is rejected unless
  ServiceControl/MessageBody/FileSystem/PathIsShared asserts the path is
  a shared mount. Nothing in the file system settings distinguishes a
  shared mount from a node local directory, so the operator has to say
  so. The same check now guards --error-ingestion-only, which PR #5801
  documented as a known gap.

No installer changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two audit entry points that were remote only become ordinary scatter
gather APIs over the new contracts, so a primary holding audit data
answers them from its own storage and still merges in any remotes.
Routes and authorization policies are unchanged: audit counts stay on
error:messages:view and sagas/{id} stays on error:sagas:view, which is
what a primary with an audit remote already serves them under.

- GetAuditCountsForEndpointApi drops the IMessagesViewDataStore it never
  used, along with the comment saying it would never be implemented here.
- GetSagaByIdApi stops deriving from ScatterGatherRemoteOnly, which
  nothing else used, so that base and its NoOpStore are gone.
- A persister without audit support falls back to an empty local source,
  registered with TryAdd after the persister, so those hosts behave
  exactly as before and the APIs still resolve.

LocalMessagesView.Merge puts the precedence, paging and counting rules in
one place for any persister that returns failed and audited messages from
one query: the failed row wins for a message that both failed and was
audited, the total counts it once, and the local result is already one
page. MessageViewComparer moves to the persistence project so a local
merge can order rows the same way the scatter gather does.

IBodyStorage documents the arbitration order for a store holding both
kinds of body, and AuditBodyKeyspace prefixes audit bodies so they do not
collide with failed message bodies in the same store.

The audit capable test persister implements all of it, so the rules have
a running implementation before the EF work starts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two primary owned capabilities depend on an audit remote existing today,
and both break once a primary holds audit data itself.

/api/connection is what ServicePulse and the Platform Connector plugin
read to configure endpoints. With no audit remote it stops advertising
MessageAudit.AuditQueue and SagaAudit.SagaAuditQueue, so endpoints cannot
be told where to send audit or saga data at all. An audit capable primary
now supplies the same two shapes the audit instance supplies, so nothing
downstream sees a difference. SagaUpdatedHandler resolves the audit queue
through the same IPlatformConnectionBuilder as before, reading either a
remote's parsed JSON or the local provider's object, so a misdirected
saga audit message is forwarded rather than failed.

Audit throughput collection is likewise driven entirely by remotes. With
local audit and no remotes, AuditQueues stays empty and the local audit
and audit.log queues are counted as customer endpoints in the licensing
throughput report, which is an accuracy defect rather than cosmetic, and
AuditServicesData comes out blank. IAuditQuery gains an optional local
source that contributes the local queue names, version, transport and
retention alongside the remotes, so the existing platform endpoint
exclusion, service metadata and connection diagnostics all work
unchanged.

Retention is reported exactly as configured. Where
ServiceControl/AuditRetentionPeriod is unset the existing minimum
retention gate warns, rather than this guessing a default: what null
means is still an open item on the plan.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds the host --audit-ingestion-only runs: an audit receiver, the
endpoint monitor it needs, and health endpoints, with no NServiceBus
endpoint and none of the work a deployment may only do once.

The component list is HeartbeatMonitoring and CustomChecks only.
HeartbeatMonitoring is there because the audit enricher asks the endpoint
monitor whether an endpoint is new before recording it, so without it
every audited message writes a known endpoint upsert. CustomChecks is
there so a stuck worker reports somewhere. EventLog and
ExternalIntegrations are absent because audit ingestion raises no domain
events and no integration events; the composition test states that, so a
future registration forces a decision rather than slipping in.

The ingestion only conditions scattered through the host become one
Settings.IngestionOnly, so both modes agree on what they do not do: no
endpoint, no retention sweep, no platform connection providers, no
heartbeat checking. Ingestion health checks move to the components that
host the ingestion they report on, so this host answers for
audit-ingestion and not for error-ingestion, and the error ingestion only
host is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
docs/audit-ingestion-in-the-primary.md covers the three deployment modes,
the settings and their key collisions with a standalone audit instance,
queue ownership and what ingestion only workers never provision, the body
storage rule, the health endpoints, and the query behavior including the
precedence, paging and counting rules.

The primary logs a warning at startup when it both ingests audit messages
and has audit remotes configured, because that is the shape most likely
to hit the setting collisions the doc describes.

An architecture test pins the boundary the plan depends on: the primary
must not reference ServiceControl.Audit, which stays a standalone
composition root. The packaging tests confirm the copied runtime ships
inside the existing primary artifact, that the three new OpenTelemetry
references change no deployment unit, and that the persisters folder is
still exactly the four shipped storages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The implementation half of the audit-in-primary work. The hosting plan
delivered contracts, runtime, settings and composition but nothing that
stores an audit message; this covers the schema, partitioning, retention,
body storage, search and queries needed to flip SupportsAuditIngestion on.

Decisions settled by interview:

- Audit tables join ServiceControlDbContext and the existing migration
  stream, with partitioning applied by raw SQL in a migration the way
  AddFullTextSearch already applies the GIN index.
- created_on is the ingestion hour and part of the primary key, because
  PostgreSQL requires the partition key in every unique constraint. A
  redelivery across an hour boundary duplicates, which is stated rather
  than claimed away.
- Audit bodies are keyed by hour so retention deletes a partition's
  bodies in one operation per store rather than one per message.
- Full text search mirrors the error side rather than the spike's
  denormalised SearchableContent column.
- Audit counts are an aggregate query, not a rollup table.
- AuditRetentionPeriod defaults to 7 days when null.
- The retention sweeper alone creates partitions, with a 48 hour
  lookahead and a custom check, since it is a single point of failure.
- Retention takes a session scoped advisory lock, ported from the spike,
  so two misconfigured primaries cannot drop the same partition at once.
  The primary EF sweeper has never had one; the locks the spike wrote
  only ever existed in the standalone audit projects.

The spike's insert-only KnownEndpoints table and its reconciler are not
ported: the primary batch writer already dedupes endpoints in memory per
batch and issues one insert-if-missing per distinct endpoint, and audit
ingestion already routes through that same unit of work.

SQL Server keeps the spike's batched deletes rather than partitioning,
because a full text index cannot be aligned to a partition scheme.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
First step of EF audit persistence. Schema only: nothing writes or reads
these tables yet, and both manifests still declare SupportsAuditIngestion
false, so no shipped instance changes behaviour.

Ingestion is the hot path and reads are not, so the schema is shaped to
make writes cheap and let queries pay.

- AuditMessages, SagaSnapshots and FailedAuditImports join the existing
  ServiceControlDbContext and the per-provider migration stream, rather
  than getting a context of their own, so an ingestion batch can commit
  audit rows and known endpoints in one transaction.
- On PostgreSQL the two audit tables are range partitioned on created_on.
  EF cannot express declarative partitioning and PostgreSQL cannot convert
  a table in place, so the migration clones each table with LIKE INCLUDING
  ALL after the columns and indexes are created. Cloning rather than hand
  writing the DDL keeps the definition owned by the entity model.
- Both audit keys are a bigint identity, and exist only because a
  partitioned table's primary key must include the partition key and
  created_on alone is not unique. Nothing reads them back. Rows are plain
  inserts with no conflict clause, so neither table deduplicates and a
  redelivered message produces a second row. RavenDB does deduplicate, so
  this is a behaviour difference and not only a scale-out caveat.
- Columns exist only where the read path filters, sorts, indexes or
  searches. InvokedSagas and OriginatesFromSaga get none, because
  InvokedSagasParser derives them from headers alone.
- ServiceControl.Persistence.EFCore now references the saga audit project
  explicitly, since transitive project references are disabled there.

The full text index is not here. SQL Server requires a single column
unique KEY INDEX and the audit key is composite, and the indexed
expression has to be written with the query expression it must match, so
both land with the queries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…igured

AuditPartitioningSql previously generated table references without a
schema qualifier, so the partitioning conversion ran against the
connection's search path rather than the configured schema. This mirrors
what FullTextSearchSql already does for the GIN index.

- Convert PartitionTables and UnpartitionTables from methods to static
  fields holding the schema-neutral form, so SchemaStampingNpgsqlMigrationsSqlGenerator
  can recognise them by identity and re-render with the correct schema.
- Add Rewrite and IsHandled to match the FullTextSearchSql pattern, and
  route SqlOperation through both in the generator.
- Qualify table names with the schema only when one is set, leaving the
  schema-less form unchanged for the default case.
- Regenerate both providers' AddAuditIngestion migrations to pick up the
  ExternalIntegrationDispatchRequests entity and a corrected EventLogItems
  table name, and mark FailingEndpointAddress required.
- Update the schema-awareness test to exclude AuditPartitioningSql
  operations alongside FullTextSearchSql ones, and fix the partitioning
  acceptance test to qualify the table name before the regclass cast.
- Remove the pending model changes test, which checked that hand-written
  DDL matched the entity model; that check is no longer valid after the
  new entity was added.
Two pipelines in one executable and one persister, segregated by folder
rather than by project: the audit pipeline under Auditing/, the error
pipeline under Operations/, shared ingestion helpers owned by neither,
audit persistence under Implementation/Audit/, the audit host's API
subset declared on the controllers, and host modes expressed as a
profile rather than as mode checks inside components.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EndpointDetailsParser and ImportFailureCircuitBreaker are used by both
the error and the audit pipeline, so they now live under
Infrastructure/Ingestion and belong to neither. The audit metrics take
the meter name from ServiceControlMeters rather than from the error
pipeline's metrics class, which was its only remaining reason to
reference Operations/. Each pipeline keeps its own metrics class since
the instruments are named per pipeline.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
EFIngestionUnitOfWork.Audit now returns a real unit of work. Recording
buffers rows and queues external body writes, as the recoverability
child does, and Complete writes them inside the transaction the failed
message batch already used, after the failed message upsert, so a
batch's audit rows and its known endpoints commit together. The
transaction moved out of FailedMessageBatchWriter into the unit of work
for that reason.

Inserts are plain multi-row statements from a per-provider dialect with
no conflict clause: nothing deduplicates, and a redelivered audit
message or saga audit message produces a second row, which the tests
assert because RavenDB behaves differently.

Every row of a batch carries the hour the batch started in. That hour
is the PostgreSQL partition key and the first segment of the external
body key, audit/{hour}/{unique message id}, which is what will let
retention drop an hour's bodies with one prefix delete. The file system
store creates that directory on write.

Partition provisioning lands here rather than with retention because on
PostgreSQL nothing can be inserted into an hour nobody provisioned: the
migrator provisions the hour before now through the 48 hour lookahead
after migrating, through IAuditPartitionManager, which is a no-op on
SQL Server.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The retention sweeper gains an audit pass. Audit rows expire an hour at
a time once the whole hour is behind the cutoff: the hour's external
bodies go first, by one prefix delete per store, then the hour's rows.
On PostgreSQL that is a DROP TABLE of the hour's partitions; on SQL
Server, which does not partition, it is a batched delete paced like the
failed message sweep. The same pass keeps the provisioned partition
window ahead of the clock, because the sweeper is the only host that
issues DDL. AuditRetentionPeriod defaults to seven days, the value the
management utility and the container image apply.

Every sweep now runs under a session scoped database lock, ported from
the audit spike: pg_try_advisory_lock on PostgreSQL, sp_getapplock on
SQL Server, on a dedicated unpooled connection, with a zero timeout so
a host that cannot take it skips the pass. The resource name carries
the configured schema, so instances sharing a database through
different schemas do not take turns. This puts error and event log
retention under the lock for the first time, deliberately: two hosts
dropping the same partition is unforgiving in a way two hosts running
the same batched delete is not.

A custom check on the PostgreSQL persister fails when the newest
provisioned partition ends less than twelve hours ahead, so a sweeper
that has stopped provisioning is visible before ingestion stops.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Each message view is one statement over both tables: the failed and
audit branches project to a common row, each carries its own sort and
limit, and the union is sorted and paged once more. The branch limits
are what make it fast, measured on PostgreSQL 16 with three million
audit rows over 168 partitions: the same union with the sort outside
made the planner hash the anti-join and scan every row, 1.2 seconds for
page one, where this shape merges two index-ordered scans in 5 ms and
takes 8 ms at page one hundred. Precedence is the rule the scatter
gather has always applied, a failed row wins whatever its status, as
an anti-join on the audit branch alone. The total is capped, because
an exact count is linear in the audit table.

Audit counts group the endpoint's non-system messages by processing
day over the last thirty days, with the RavenDB persister's zero for an
endpoint that only ever sent. Saga history pages a saga's snapshots
newest first. Body arbitration gains its third step, the audit row by
unique message id, with the external key composed in memory because a
Guid rendered by SQL Server is upper case.

The audit full-text index mirrors the failed one on both providers and
is pinned to the query expression by the same test. On SQL Server it
keys on a unique index over the identity column, since a full-text key
index must be a single column and the audit primary key is composite.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The audit counterpart of the failed error import store: keyed by
FailedAuditImport.DeriveKey so a poison message produces one row
however many workers attempt it, bodies over the cap spilled to
external storage under the failedauditimport prefix, replay oldest
first with failures left in place, and no retention. The reimport
command already drives the contract, so it needs no change.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Both manifests now set SupportsAuditIngestion, so a primary on either
persister hosts the audit runtime: the receiver, saga audit, failed
audit imports, the local audit queries and audit retention. The
acceptance suites run against the real persisters, which retires the
audit capable test persister that stood in for them, along with the
scenarios that could only exist while a relational persister lacked
audit support.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A primary whose audit load should not share its database can move audit
to a dedicated one. ServiceControl.exe --audit-instance is the owner of
that database: it ingests the audit queue, serves the primary API so the
primary's scatter gather can list it under RemoteInstances, sweeps audit
retention, and with --setup provisions the database, the audit queue
and body storage. The primary is told the data is elsewhere through
ServiceControl/AuditDataLocation=Remote, which turns off its audit
receiver, its local audit queries, its audit retention pass and its
partition provisioning, and replaces the old warning about remotes plus
local ingestion with a validation error.

Hosts on the audit database report to the primary the way the RavenDB
audit instance does, custom check results and detected endpoints sent
to ServiceControl/ServiceControlQueueAddress, through a send only
endpoint built from the transport customization's audit profile. A
worker with that setting reports too, so scaling out the audit host
needs no new flag.

Components no longer branch on the mode name. Settings.Host is a
profile derived once from the flags and each component asks for the
capability it needs, which the third host mode made worth having.
Workers gain a schema probe that refuses to start against a database
the owner has not migrated yet, naming setup instead of failing on the
first insert.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The shipped hosting document gains the shared and dedicated audit
database topologies with the processes each runs, the AuditDataLocation
and ServiceControlQueueAddress settings, the seven day retention
default, the hourly retention model and its lock, and the database-side
message view with its precedence and capped total. The hosting plan's
statement that there is no separate audit HTTP service is marked
superseded.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant