fix(auditlog): truncate over-width SiteCalls.LastError on the cached-telemetry dual-write and the reconciliation pull (review 2026-10-21 follow-up)

This commit is contained in:
Joseph Doherty
2026-10-22 09:52:17 -04:00
parent 89d8568fdf
commit 7be22d2ac2
6 changed files with 121 additions and 4 deletions
@@ -160,6 +160,37 @@ public class AuditLogIngestActorCombinedTelemetryTests : TestKit, IClassFixture<
Assert.Equal(siteCall.Status, siteCallRow!.Status);
}
/// <summary>
/// Review 2026-10-21 follow-up: a site LastError longer than the central
/// <c>SiteCalls.LastError</c> column (nvarchar(1024)) used to fail the dual-write with SQL
/// 8152 — the audit row was reported Rejected and the SiteCalls row was never upserted. The
/// ingest now truncates it to the column width, so both rows land.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task Receive_OverWidthLastError_IsTruncated_BothRowsLand()
{
Skip.IfNot(_fixture.Available, _fixture.SkipReason);
var siteId = NewSiteId();
var (audit, siteCall) = NewEntry(siteId);
siteCall = siteCall with { LastError = new string('x', 5000) };
var actor = CreateActor(BuildServiceProvider());
actor.Tell(
new IngestCachedTelemetryCommand(new[] { new CachedTelemetryEntry(audit, siteCall) }),
TestActor);
var reply = ExpectMsg<IngestCachedTelemetryReply>(TimeSpan.FromSeconds(15));
Assert.Equal(new[] { audit.EventId }, reply.AcceptedEventIds);
Assert.Empty(reply.RejectedEventIds);
await using var read = CreateReadContext();
var siteCallRow = await read.Set<SiteCall>()
.SingleAsync(s => s.TrackedOperationId == siteCall.TrackedOperationId);
Assert.Equal(SiteCallColumnBounds.LastErrorMaxLength, siteCallRow.LastError!.Length);
}
/// <summary>Verifies that a duplicate event id with an unchanged status is a no-op that leaves the row counts unchanged and still acknowledges the id.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
@@ -274,6 +274,45 @@ public class GrpcPullSiteCallsClientTests
Assert.False(result.MoreAvailable);
}
/// <summary>
/// Review 2026-10-21 follow-up: a pulled LastError longer than the central SiteCalls column
/// is truncated to the column width, so the reconciliation upsert cannot fail with SQL 8152.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task PullAsync_truncates_over_width_last_error_to_the_column_width()
{
var proto = new ProtoPullResponse();
var dto = Dto(Guid.NewGuid(), BaseTime);
dto.LastError = new string('e', 5000);
proto.Operationals.Add(dto);
var sut = new GrpcPullSiteCallsClient(
new StaticEnumerator(new SiteEntry("site-a", "http://site-a:8083")),
FakeInvoker.Returning(proto),
NullLogger<GrpcPullSiteCallsClient>.Instance);
var result = await sut.PullAsync("site-a", BaseTime, afterId: null, batchSize: 256, CancellationToken.None);
Assert.Equal(SiteCallColumnBounds.LastErrorMaxLength, Assert.Single(result.SiteCalls).LastError!.Length);
}
/// <summary>A surrogate pair straddling the cut is never split; short values pass through untouched.</summary>
[Fact]
public void SiteCallColumnBounds_Clamp_KeepsSurrogatePairsWhole_AndLeavesShortValuesAlone()
{
static ZB.MOM.WW.ScadaBridge.Commons.Entities.Audit.SiteCall Call(string lastError) =>
SiteCallDtoMapper.FromDto(Dto(Guid.NewGuid(), BaseTime)) with { LastError = lastError };
var shortCall = Call("boom");
Assert.Same(shortCall, SiteCallColumnBounds.Clamp(shortCall));
var straddle = new string('a', SiteCallColumnBounds.LastErrorMaxLength - 2) + "\U0001F600" + "tail";
var clamped = SiteCallColumnBounds.Clamp(Call(straddle)).LastError!;
Assert.True(clamped.Length <= SiteCallColumnBounds.LastErrorMaxLength);
Assert.False(char.IsHighSurrogate(clamped[^1]));
}
/// <summary>Verifies that PullAsync with a MinValue unspecified-kind cursor does not throw and still dials the site.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]