14 KiB
Component: Site Call Audit
Purpose
Provides central, queryable audit and operational visibility for cached calls
made by site scripts — ExternalSystem.CachedCall() and Database.CachedWrite().
Each such call carries a TrackedOperationId; sites report lifecycle telemetry
to this component, which maintains a central audit record, computes KPIs, and
relays Retry/Discard actions back to the owning site.
This is the second centrally-hosted observability component for site store-and-forward activity (the Notification Outbox is the first). Unlike the Notification Outbox, Site Call Audit is not a dispatcher — it never delivers anything. Cached calls are delivered by the site's Store-and-Forward Engine against site-local external systems and databases, which central cannot reach.
Location
Central cluster only. A singleton actor (SiteCallAuditActor) on the active
central node. Registered as component #22 in the Host role configuration.
Responsibilities
- Ingest cached-call lifecycle telemetry from sites into the central
SiteCallstable. - Run periodic per-site reconciliation pulls so missed telemetry self-heals.
- Compute point-in-time KPIs (global and per-site) from the
SiteCallstable. - Relay operator Retry/Discard actions for parked cached calls to the owning site over the command/control channel.
- Purge terminal audit rows after a configurable retention window.
The SiteCalls Table
Lives in the central MS SQL configuration database — a sibling of the
Notifications table. One row per TrackedOperationId (the shipped columns, as
mapped by SiteCallEntityTypeConfiguration — the source of truth is
Component-ConfigurationDatabase.md § Site Calls):
- TrackedOperationId — GUID (
varchar(36), "D"-format), primary key. Generated site-side at call time. - Channel —
varchar(32), the trust-boundary channel that produced the call:ApiOutbound(ExternalSystem.CachedCall()) orDbOutbound(Database.CachedWrite()). - Target —
varchar(256), human-readable target (e.g.ERP.GetOrderfor an external call, or the database connection name for a cached write — intentionally not the SQL statement or table, a deliberate scoping choice). - SourceSite —
varchar(64), site that issued the call. - SourceNode —
varchar(64)NULL, the cluster node on which the call was issued (node-a/node-b, qualified bySourceSite). Stamped site-side at submit time and carried verbatim through the combinedCachedCallTelemetrypacket, reconciliation pulls, and the central upsert; NULL for reconciled rows from a retired node. - Status —
varchar(32), theAuditStatusenum name (not the tracking-lifecycle names):Submitted,Forwarded,Attempted,Delivered,Failed,Parked,Discarded. The lifecycle is monotonic, so out-of-order/at-least-once telemetry is harmless. - RetryCount —
int, attempts so far. - LastError —
nvarchar(1024)NULL, most recent error detail, if any. - HttpStatus —
intNULL, last HTTP status code for API calls. - CreatedAtUtc, UpdatedAtUtc (
datetime2), TerminalAtUtc (datetime2NULL), IngestedAtUtc (datetime2, central ingest timestamp) — key timestamps.
There are no Kind, TargetSummary, or provenance (instance/script) columns —
those were an earlier design that did not ship; provenance detail for a cached call
lives on the site's own tracking store and in the AuditLog rows.
Status Lifecycle
Pending → Retrying → Delivered / Parked / Failed / Discarded
Stored vs. tracking view. This lifecycle is the operator-facing tracking view (what the site's tracking store and
Tracking.Status()express). The persistedSiteCalls.Statuscolumn stores the monotonicAuditStatus-derived string (Submitted/Forwarded/Attempted/Delivered/Failed/Parked/Discarded) carried by the combined telemetry packet — the mirror records the audit-event status, not the tracking enum name. The two agree on the terminal outcomes; the non-terminalSubmitted/Forwarded/Attemptedstrings are the ingest-phase equivalents ofPending/Retrying.
- Pending — non-terminal: buffered after a transient failure, awaiting its first retry.
- Retrying — non-terminal: undergoing retry attempts.
- Delivered — terminal, success. A cached call that succeeds on its first
immediate attempt is recorded directly as
Delivered. - Parked — non-terminal: transient retries exhausted; awaiting manual action.
- Failed — terminal: permanent failure (e.g. HTTP 4xx). The error was also
returned synchronously to the calling script; the record captures it.
Failedrows are not operator-actionable — see Retry / Discard Relay. - Discarded — terminal, reached only by operator action on a
Parkedrow. The row is kept (not deleted) so the table remains a complete audit record.
The site is the source of truth. The SiteCalls row is an eventually-consistent
mirror — never queried by scripts (Tracking.Status() is answered site-locally).
Ingest & Idempotency
Telemetry ingestion is insert-if-not-exists keyed on TrackedOperationId,
then upsert-on-newer-status, with a newest-UpdatedAtUtc tiebreaker within
equal non-terminal rank. The lifecycle is monotonic on status rank, so status
never regresses. Within an equal non-terminal rank (the Attempted/Skipped
retry phase), the packet with the newest UpdatedAtUtc wins — so a retrying
call's RetryCount/LastError/HttpStatus stay live instead of freezing at the
first Attempted write. Equal terminal rank stays immutable (a later
Delivered never overwrites an earlier Parked), equal stamps are an idempotent
no-op, and a lower rank is always a no-op — so at-least-once and out-of-order
telemetry remain harmless.
From v1.x onward, the CachedCallTelemetry message additively carries the
AuditEvent content alongside the existing operational fields. Central's
AuditLogIngestActor (Audit Log #23) performs both the immutable AuditLog
insert and the SiteCalls upsert in a single transaction. Idempotency keys
remain EventId (for AuditLog) and TrackedOperationId (for SiteCalls).
See Component-AuditLog.md, Cached Operations —
Combined Telemetry, for the dual-write contract.
Reconciliation
Because telemetry is best-effort, SiteCallAuditActor periodically — and on site
reconnect — pulls "all tracking rows changed since cursor X" from each site.
Gaps left by lost telemetry self-heal. Central converges to the site; the site
never depends on central.
The per-site cursor is a composite (UpdatedAtUtc, TrackedOperationId)
keyset, not a single timestamp. Each pull asks for rows strictly greater than
the cursor pair and advances it to the maximum row seen; a burst of more rows
than one batch all sharing one exact UpdatedAtUtc therefore drains via the
TrackedOperationId tiebreak instead of pinning the timestamp forever. The
after_id keyset field is additive on the pull contract — a first pull (or a
legacy site that predates it) sends no after_id and keeps the inclusive
>= timestamp behaviour. When such a legacy site keeps reporting
MoreAvailable yet the composite cursor cannot advance, the actor latches the
site as pinned and publishes SiteCallReconciliationPinnedChanged(siteId, Pinned) on the EventStream (transition-only, mirroring
SiteAuditTelemetryStalledChanged) — the un-drainable tail is a
health-observable condition rather than a silent log line, and the latch clears
with Pinned=false once a later tick makes progress.
The drain runs off the mailbox. A reconciliation pass is unbounded work —
every site, up to a page ceiling of network pulls each, one upsert per row — so
it runs as a background task with a PipeTo-delivered completion message and a
single-flight guard, not as an actor message handler. A handler occupies the
actor for its whole duration, so a post-outage catch-up used to park telemetry
ingest, UI queries and KPI Asks behind it; those callers time out rather than
queue, which made a slow site look like a dead central. The single-flight guard
is raised and lowered ON the actor thread, so the per-site cursor and pinned-latch
dictionaries the pass mutates are still only ever touched by one task at a time,
and the mailbox supplies the memory barrier between consecutive passes. The
daily terminal-row purge uses the same shape. (NotificationOutboxActor's
dispatch sweep is the in-repo reference.)
Stored site ownership is immutable. Authenticated control-plane requests must claim their verified site in both audit and operational halves. An existing TrackedOperationId cannot be advanced by another site even when the incoming packet claims its own site consistently: the SQL upsert rejects the owner mismatch before its monotonic update, including the duplicate-insert retry path. The update predicate independently requires an exact owner match. A rejection rolls back the paired audit/operational ingest transaction and produces no acknowledgment.
The central upsert is one round trip, INSERT-first.
SiteCallAuditRepository.UpsertAsync ships both statements in a single command
text — IF NOT EXISTS … INSERT; then the monotonic UPDATE — so a packet costs
one round trip, not two. The insert leg is gated on the row's existence alone,
never on "the update matched nothing": a zero row count also means "the monotonic
guard rejected this packet", and inserting there would fork the mirror with a
second row for an id that already exists.
The ORDER is load-bearing, and running the UPDATE first is a data-loss bug. The
two writers — the cached dual-write and the reconciliation pull — routinely carry
different lifecycle states for the same TrackedOperationId, and both can race
its very first packet. Under UPDATE-first both find no row, so both updates match
nothing; the loser then either fails its existence re-check and skips its insert,
or attempts it and takes a duplicate-key fault — either way its
Status/RetryCount/HttpStatus/TerminalAtUtc are dropped, because it never
ran an update against the winner's row. Under INSERT-first the loser's insert is
skipped or faults and its monotonic update still applies to whichever row won, so
the newer state survives every interleaving while a stale one is still rejected by
the rank guard. The duplicate-key catch re-runs the monotonic update for the same
reason (it is idempotent and rank-guarded, so a redundant re-run is inert).
Retry / Discard Relay
Parked cached calls live in the owning site's S&F buffer. Operator Retry/Discard
from the Central UI is relayed to that site as a RetryParkedOperation /
DiscardParkedOperation command over the command/control channel. The site
applies the change and emits telemetry reflecting the new state; central never
mutates the SiteCalls row directly. If the site is offline the command fails
fast and the UI surfaces a "site unreachable" message.
On a successful relay (the site acks Applied), SiteCallAuditActor emits
one best-effort central direct-write audit row (CachedResolve, status
Submitted for a Retry / Discarded for a Discard) carrying the authenticated
operator as Actor and the TrackedOperationId as CorrelationId — recording
who asked. The operator identity flows in on RetrySiteCallRequest /
DiscardSiteCallRequest (RequestedBy, captured at the Central UI). This row
only adds provenance: the site remains the source of truth for the state
change itself, and central reads the stored SiteCalls row solely to enrich the
audit row's channel/target (a benign read, never a mutation). Audit is
best-effort — a writer fault never changes the relay outcome.
Only Parked rows are operator-actionable. Failed rows offer no Retry or
Discard: a permanent failure (e.g. HTTP 4xx) would simply fail again, and the
error was already returned synchronously to the calling script — there is
nothing for an operator to recover.
KPIs
Point-in-time, computed from the SiteCalls table, global and per-source-site,
mirroring the Notification Outbox KPI shape:
- Buffered count (
Pending+Retrying) - Parked count
- Failed-last-interval
- Delivered-last-interval
- Oldest-pending age
- Stuck count —
Pending/Retryingolder than a configurable threshold (default 10 minutes); display-only, no escalation.
Retention
Daily purge of terminal rows (Delivered, Failed, Discarded) after a
configurable window (default 365 days), matching the Notifications purge.
Dependencies
- Configuration Database: hosts the
SiteCallstable and its repository. - Central–Site Communication: receives cached-call telemetry and reconciliation responses; sends Retry/Discard commands.
- Store-and-Forward Engine: the site-side origin of cached-call telemetry and the executor of relayed Retry/Discard commands.
- Audit Log (#23): shares the
CachedCallTelemetrypacket — each lifecycle transition (CachedEnqueued,CachedAttempt,CachedTerminal) carries anAuditEventalongside the operational fields, and central'sAuditLogIngestActorperforms theAuditLoginsert and theSiteCallsupsert in a single transaction (see Component-AuditLog.md, Cached Operations — Combined Telemetry). - Commons:
TrackedOperationId, status enum, telemetry message contracts.
Interactions
- Central UI: the Site Calls page queries this component and issues Retry/Discard actions.
- Health Monitoring: surfaces Site Call Audit KPI tiles on the dashboard.
- Cluster Infrastructure: hosts the
SiteCallAuditActorsingleton with active/standby failover. - KPI History (#26): emits
IKpiSampleSource(SiteCallAuditKpiSampleSource, Global + per-Site + per-Node) consumed by the KpiHistory recorder (#26), reusing the existing KPI reads. All six metrics —buffered/parked/failedLastInterval/deliveredLastInterval/stuck/oldestPendingAgeSeconds— are sampled into theKpiSamplehistory store, but only the three charted via the publicKpiMetrics.SiteCallAuditcatalog (buffered/parked/failedLastInterval) render as trends on the Site Calls page viaKpiTrendChart;deliveredLastInterval/stuck/oldestPendingAgeSecondsare sampled-but-not-yet-charted (available for future trend panels / ad-hoc query). See Component-KpiHistory.md.