Bundle B1 of Audit Log #23 M3: introduces the SiteCall entity + EF mapping for the central SiteCalls operational-state table. One row per TrackedOperationId, mirrored from sites via best-effort telemetry then periodic reconciliation; eventually-consistent mirror, not a dispatcher. - src/ScadaLink.Commons/Entities/Audit/SiteCall.cs: append-once record with required TrackedOperationId/Channel/Target/SourceSite/Status, monotonic status update at the repo layer. - src/ScadaLink.ConfigurationDatabase/Configurations/SiteCallEntityTypeConfiguration.cs: table SiteCalls, PK on TrackedOperationId (stored as varchar(36) via value conversion through the canonical 'D'-format GUID string — matches the wire shape used by gRPC + SQLite columns), two named indexes (IX_SiteCalls_Source_Created, IX_SiteCalls_Status_Updated). - ScadaLinkDbContext: DbSet<SiteCall> SiteCalls in the existing Audit section, after AuditLogs. - Tests in tests/ScadaLink.ConfigurationDatabase.Tests/Configurations/: table name, PK, value-conversion shape, index presence + ordering.
This commit is contained in:
60
src/ScadaLink.Commons/Entities/Audit/SiteCall.cs
Normal file
60
src/ScadaLink.Commons/Entities/Audit/SiteCall.cs
Normal file
@@ -0,0 +1,60 @@
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.Commons.Entities.Audit;
|
||||
|
||||
/// <summary>
|
||||
/// Central operational state row for a cached call (Site Call Audit #22, Audit Log #23 M3).
|
||||
/// One row per <see cref="TrackedOperationId"/> in the <c>SiteCalls</c> table — append-once
|
||||
/// then monotonic status update. Status transitions are forward-only
|
||||
/// (<c>Submitted → Forwarded → Attempted → Delivered|Failed|Parked|Discarded</c>); an
|
||||
/// out-of-order or duplicate upsert is a silent no-op so duplicate gRPC packets and
|
||||
/// reconciliation pulls can both feed the same writer without rolling state back.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Sites remain the source of truth — this row is the eventually-consistent mirror the
|
||||
/// Central UI's Site Calls page reads. Unlike the partitioned <c>AuditLog</c> table this
|
||||
/// entity backs operational (mutable) state on a standard non-partitioned table on
|
||||
/// <c>[PRIMARY]</c>; no DB-role restriction applies.
|
||||
/// </remarks>
|
||||
public sealed record SiteCall
|
||||
{
|
||||
/// <summary>Strong-typed idempotency key shared with every audit row for the operation.</summary>
|
||||
public required TrackedOperationId TrackedOperationId { get; init; }
|
||||
|
||||
/// <summary>Trust-boundary channel — <c>"ApiOutbound"</c> or <c>"DbOutbound"</c>.</summary>
|
||||
public required string Channel { get; init; }
|
||||
|
||||
/// <summary>Human-readable target (e.g. <c>"ERP.GetOrder"</c>).</summary>
|
||||
public required string Target { get; init; }
|
||||
|
||||
/// <summary>Site id that submitted the cached call.</summary>
|
||||
public required string SourceSite { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// Lifecycle status — string form of
|
||||
/// <see cref="ScadaLink.Commons.Types.Enums.AuditStatus"/>. Monotonic: later rank
|
||||
/// wins, earlier rank is a no-op.
|
||||
/// </summary>
|
||||
public required string Status { get; init; }
|
||||
|
||||
/// <summary>Number of dispatch attempts so far; 0 prior to first attempt.</summary>
|
||||
public required int RetryCount { get; init; }
|
||||
|
||||
/// <summary>Most recent error message; null when no failures have occurred.</summary>
|
||||
public string? LastError { get; init; }
|
||||
|
||||
/// <summary>Most recent HTTP status code (API calls only); null otherwise.</summary>
|
||||
public int? HttpStatus { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp the cached call was first submitted at the site.</summary>
|
||||
public required DateTime CreatedAtUtc { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp of the latest status mutation at the site.</summary>
|
||||
public required DateTime UpdatedAtUtc { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp the row reached a terminal status; null while still active.</summary>
|
||||
public DateTime? TerminalAtUtc { get; init; }
|
||||
|
||||
/// <summary>UTC timestamp central ingested (or last refreshed) this row.</summary>
|
||||
public required DateTime IngestedAtUtc { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Metadata.Builders;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.Commons.Types;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Maps the <see cref="SiteCall"/> record to the central <c>SiteCalls</c> table
|
||||
/// (Site Call Audit #22, Audit Log #23 M3 Bundle B). Operational state — NOT audit —
|
||||
/// so the table is non-partitioned, standard <c>[PRIMARY]</c> filegroup, no DB-role
|
||||
/// restriction. Two named indexes back the Central UI's "from this site" and
|
||||
/// "in this status" queries.
|
||||
/// </summary>
|
||||
public class SiteCallEntityTypeConfiguration : IEntityTypeConfiguration<SiteCall>
|
||||
{
|
||||
public void Configure(EntityTypeBuilder<SiteCall> builder)
|
||||
{
|
||||
builder.ToTable("SiteCalls");
|
||||
|
||||
// PK is the strong-typed TrackedOperationId. Stored as varchar(36) by converting
|
||||
// through the canonical "D"-format GUID string. Going through the string surface
|
||||
// (rather than uniqueidentifier) keeps the column shape identical to how the id
|
||||
// is serialised on the wire (gRPC strings, SQLite TEXT on the site) — one
|
||||
// consistent format everywhere makes operational debugging far easier than
|
||||
// mixing a uniqueidentifier central column with TEXT site columns.
|
||||
builder.HasKey(s => s.TrackedOperationId);
|
||||
|
||||
builder.Property(s => s.TrackedOperationId)
|
||||
.HasConversion(
|
||||
id => id.Value.ToString("D"),
|
||||
s => new TrackedOperationId(Guid.Parse(s)))
|
||||
.HasMaxLength(36)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Enum-as-string columns: bounded varchar, ASCII.
|
||||
builder.Property(s => s.Channel)
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Status)
|
||||
.HasMaxLength(32)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.SourceSite)
|
||||
.HasMaxLength(64)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(s => s.Target)
|
||||
.HasMaxLength(256)
|
||||
.IsUnicode(false)
|
||||
.IsRequired();
|
||||
|
||||
// Bounded unicode message column.
|
||||
builder.Property(s => s.LastError)
|
||||
.HasMaxLength(1024);
|
||||
|
||||
// Indexes — names locked for reconciliation/migration discoverability.
|
||||
// Source_Created backs "calls from this site" (Central UI Site Calls page,
|
||||
// filter by SourceSite, newest first).
|
||||
builder.HasIndex(s => new { s.SourceSite, s.CreatedAtUtc })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_SiteCalls_Source_Created");
|
||||
|
||||
// Status_Updated backs "calls in this status" (e.g. parked rows awaiting
|
||||
// operator action, newest UpdatedAtUtc first).
|
||||
builder.HasIndex(s => new { s.Status, s.UpdatedAtUtc })
|
||||
.IsDescending(false, true)
|
||||
.HasDatabaseName("IX_SiteCalls_Status_Updated");
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,7 @@ public class ScadaLinkDbContext : DbContext, IDataProtectionKeyContext
|
||||
// Audit
|
||||
public DbSet<AuditLogEntry> AuditLogEntries => Set<AuditLogEntry>();
|
||||
public DbSet<AuditEvent> AuditLogs => Set<AuditEvent>();
|
||||
public DbSet<SiteCall> SiteCalls => Set<SiteCall>();
|
||||
|
||||
// Data Protection Keys (for shared ASP.NET Data Protection across nodes)
|
||||
public DbSet<DataProtectionKey> DataProtectionKeys => Set<DataProtectionKey>();
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ScadaLink.Commons.Entities.Audit;
|
||||
using ScadaLink.ConfigurationDatabase;
|
||||
using ScadaLink.ConfigurationDatabase.Configurations;
|
||||
|
||||
namespace ScadaLink.ConfigurationDatabase.Tests.Configurations;
|
||||
|
||||
/// <summary>
|
||||
/// Schema-level tests for <see cref="SiteCallEntityTypeConfiguration"/> (#22 / #23 M3 Bundle B).
|
||||
/// Verifies the <see cref="SiteCall"/> record maps to the <c>SiteCalls</c> table with the
|
||||
/// expected primary key, value conversion on <c>TrackedOperationId</c>, and the two named
|
||||
/// indexes that back the "calls from this site" and "calls in this status" Central UI queries.
|
||||
/// Mirrors the AuditLog Bundle B test pattern — inspects EF model metadata via the existing
|
||||
/// in-memory SQLite test context, no database round-trips required.
|
||||
/// </summary>
|
||||
public class SiteCallEntityTypeConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly ScadaLinkDbContext _context;
|
||||
|
||||
public SiteCallEntityTypeConfigurationTests()
|
||||
{
|
||||
_context = SqliteTestHelper.CreateInMemoryContext();
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_context.Database.CloseConnection();
|
||||
_context.Dispose();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_MapsToSiteCallsTable()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
|
||||
Assert.NotNull(entity);
|
||||
Assert.Equal("SiteCalls", entity!.GetTableName());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_PrimaryKey_TrackedOperationId()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var pk = entity!.FindPrimaryKey();
|
||||
Assert.NotNull(pk);
|
||||
|
||||
var pkPropertyNames = pk!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(SiteCall.TrackedOperationId) }, pkPropertyNames);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_HasIndexes_NamedAndOrdered()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var indexes = entity!.GetIndexes().ToList();
|
||||
|
||||
// IX_SiteCalls_Source_Created: (SourceSite ASC, CreatedAtUtc DESC).
|
||||
var sourceCreated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Source_Created");
|
||||
Assert.NotNull(sourceCreated);
|
||||
var sourceCreatedProps = sourceCreated!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(SiteCall.SourceSite), nameof(SiteCall.CreatedAtUtc) }, sourceCreatedProps);
|
||||
|
||||
// IX_SiteCalls_Status_Updated: (Status ASC, UpdatedAtUtc DESC).
|
||||
var statusUpdated = indexes.SingleOrDefault(i => i.GetDatabaseName() == "IX_SiteCalls_Status_Updated");
|
||||
Assert.NotNull(statusUpdated);
|
||||
var statusUpdatedProps = statusUpdated!.Properties.Select(p => p.Name).ToArray();
|
||||
Assert.Equal(new[] { nameof(SiteCall.Status), nameof(SiteCall.UpdatedAtUtc) }, statusUpdatedProps);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Configure_TrackedOperationId_ConvertedToString_Length36()
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var property = entity!.FindProperty(nameof(SiteCall.TrackedOperationId));
|
||||
Assert.NotNull(property);
|
||||
|
||||
// Stored as varchar(36) (TrackedOperationId.ToString("D") is always 36 chars).
|
||||
// The value-conversion target type is exposed via GetProviderClrType when set, or
|
||||
// discovered indirectly through the configured converter; either way the on-wire
|
||||
// CLR type is string.
|
||||
var providerClrType = property!.GetProviderClrType() ?? property.GetValueConverter()?.ProviderClrType;
|
||||
Assert.Equal(typeof(string), providerClrType);
|
||||
Assert.Equal(36, property.GetMaxLength());
|
||||
Assert.False(property.IsUnicode() ?? true);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(nameof(SiteCall.Channel), 32)]
|
||||
[InlineData(nameof(SiteCall.SourceSite), 64)]
|
||||
[InlineData(nameof(SiteCall.Status), 32)]
|
||||
[InlineData(nameof(SiteCall.Target), 256)]
|
||||
public void Configure_AsciiBoundedColumns(string propertyName, int expectedMaxLength)
|
||||
{
|
||||
var entity = _context.Model.FindEntityType(typeof(SiteCall));
|
||||
Assert.NotNull(entity);
|
||||
|
||||
var property = entity!.FindProperty(propertyName);
|
||||
Assert.NotNull(property);
|
||||
|
||||
Assert.Equal(expectedMaxLength, property!.GetMaxLength());
|
||||
Assert.False(property.IsUnicode() ?? true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user