From 50430b9daa58fd72c164d23e2ac9904069e3d906 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 21 May 2026 17:12:34 -0400 Subject: [PATCH] feat(auditlog): ParentExecutionId on site SQLite schema + gRPC AuditEventDto --- .../Site/SqliteAuditWriter.cs | 22 ++- .../Grpc/AuditEventDtoMapper.cs | 2 + .../Protos/sitestream.proto | 1 + .../SiteStreamGrpc/Sitestream.cs | 117 ++++++++++----- .../Site/SqliteAuditWriterSchemaTests.cs | 140 +++++++++++++++++- .../AuditEventDtoMapperTests.cs | 6 + 6 files changed, 241 insertions(+), 47 deletions(-) diff --git a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs index 72c69d0..f38d99f 100644 --- a/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs +++ b/src/ScadaLink.AuditLog/Site/SqliteAuditWriter.cs @@ -115,6 +115,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Extra TEXT NULL, ForwardState TEXT NOT NULL, ExecutionId TEXT NULL, + ParentExecutionId TEXT NULL, PRIMARY KEY (EventId) ); CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred @@ -135,6 +136,14 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable // nullable with no default, so any row written before this migration // reads back ExecutionId = null (back-compat). AddColumnIfMissing("ExecutionId", "TEXT NULL"); + + // Audit Log #23 (ParentExecutionId): same idempotent upgrade path as + // ExecutionId above. A deployment that already ran the ExecutionId + // branch has an auditlog.db with the 21-column schema and no + // ParentExecutionId column; CREATE TABLE IF NOT EXISTS cannot add it, + // so it is ALTER-ed in here. Nullable with no default — rows written + // before this migration read back ParentExecutionId = null. + AddColumnIfMissing("ParentExecutionId", "TEXT NULL"); } /// @@ -263,13 +272,13 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId ) VALUES ( $EventId, $OccurredAtUtc, $Channel, $Kind, $CorrelationId, $SourceSiteId, $SourceInstanceId, $SourceScript, $Actor, $Target, $Status, $HttpStatus, $DurationMs, $ErrorMessage, $ErrorDetail, $RequestSummary, $ResponseSummary, $PayloadTruncated, $Extra, $ForwardState, - $ExecutionId + $ExecutionId, $ParentExecutionId ); """; @@ -294,6 +303,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable var pExtra = cmd.Parameters.Add("$Extra", SqliteType.Text); var pForwardState = cmd.Parameters.Add("$ForwardState", SqliteType.Text); var pExecutionId = cmd.Parameters.Add("$ExecutionId", SqliteType.Text); + var pParentExecutionId = cmd.Parameters.Add("$ParentExecutionId", SqliteType.Text); foreach (var pending in batch) { @@ -319,6 +329,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable pExtra.Value = (object?)e.Extra ?? DBNull.Value; pForwardState.Value = (e.ForwardState ?? AuditForwardState.Pending).ToString(); pExecutionId.Value = (object?)e.ExecutionId?.ToString() ?? DBNull.Value; + pParentExecutionId.Value = (object?)e.ParentExecutionId?.ToString() ?? DBNull.Value; try { @@ -377,7 +388,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId FROM AuditLog WHERE ForwardState = $pending ORDER BY OccurredAtUtc ASC, EventId ASC @@ -426,7 +437,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId FROM AuditLog WHERE ForwardState = $forwarded ORDER BY OccurredAtUtc ASC, EventId ASC @@ -513,7 +524,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable SourceSiteId, SourceInstanceId, SourceScript, Actor, Target, Status, HttpStatus, DurationMs, ErrorMessage, ErrorDetail, RequestSummary, ResponseSummary, PayloadTruncated, Extra, ForwardState, - ExecutionId + ExecutionId, ParentExecutionId FROM AuditLog WHERE ForwardState IN ($pending, $forwarded) AND OccurredAtUtc >= $since @@ -691,6 +702,7 @@ public class SqliteAuditWriter : IAuditWriter, ISiteAuditQueue, IAsyncDisposable Extra = reader.IsDBNull(18) ? null : reader.GetString(18), ForwardState = Enum.Parse(reader.GetString(19)), ExecutionId = reader.IsDBNull(20) ? null : Guid.Parse(reader.GetString(20)), + ParentExecutionId = reader.IsDBNull(21) ? null : Guid.Parse(reader.GetString(21)), }; } diff --git a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs index 4a679e7..640cb13 100644 --- a/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs +++ b/src/ScadaLink.Communication/Grpc/AuditEventDtoMapper.cs @@ -48,6 +48,7 @@ public static class AuditEventDtoMapper Kind = evt.Kind.ToString(), CorrelationId = evt.CorrelationId?.ToString() ?? string.Empty, ExecutionId = evt.ExecutionId?.ToString() ?? string.Empty, + ParentExecutionId = evt.ParentExecutionId?.ToString() ?? string.Empty, SourceSiteId = evt.SourceSiteId ?? string.Empty, SourceInstanceId = evt.SourceInstanceId ?? string.Empty, SourceScript = evt.SourceScript ?? string.Empty, @@ -94,6 +95,7 @@ public static class AuditEventDtoMapper Kind = Enum.Parse(dto.Kind), CorrelationId = NullIfEmpty(dto.CorrelationId) is { } cid ? Guid.Parse(cid) : null, ExecutionId = NullIfEmpty(dto.ExecutionId) is { } eid ? Guid.Parse(eid) : null, + ParentExecutionId = NullIfEmpty(dto.ParentExecutionId) is { } pid ? Guid.Parse(pid) : null, SourceSiteId = NullIfEmpty(dto.SourceSiteId), SourceInstanceId = NullIfEmpty(dto.SourceInstanceId), SourceScript = NullIfEmpty(dto.SourceScript), diff --git a/src/ScadaLink.Communication/Protos/sitestream.proto b/src/ScadaLink.Communication/Protos/sitestream.proto index 9c671e9..dccad81 100644 --- a/src/ScadaLink.Communication/Protos/sitestream.proto +++ b/src/ScadaLink.Communication/Protos/sitestream.proto @@ -92,6 +92,7 @@ message AuditEventDto { bool payload_truncated = 18; string extra = 19; string execution_id = 20; // empty string represents null + string parent_execution_id = 21; // empty string represents null } message AuditEventBatch { repeated AuditEventDto events = 1; } diff --git a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs index d591e78..41e11e4 100644 --- a/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs +++ b/src/ScadaLink.Communication/SiteStreamGrpc/Sitestream.cs @@ -41,7 +41,7 @@ namespace ScadaLink.Communication.Grpc { "c3RhdGUYAyABKA4yGi5zaXRlc3RyZWFtLkFsYXJtU3RhdGVFbnVtEhAKCHBy", "aW9yaXR5GAQgASgFEi0KCXRpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5wcm90", "b2J1Zi5UaW1lc3RhbXASKQoFbGV2ZWwYBiABKA4yGi5zaXRlc3RyZWFtLkFs", - "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiiwQKDUF1ZGl0RXZlbnRE", + "YXJtTGV2ZWxFbnVtEg8KB21lc3NhZ2UYByABKAkiqAQKDUF1ZGl0RXZlbnRE", "dG8SEAoIZXZlbnRfaWQYASABKAkSMwoPb2NjdXJyZWRfYXRfdXRjGAIgASgL", "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIPCgdjaGFubmVsGAMgASgJ", "EgwKBGtpbmQYBCABKAkSFgoOY29ycmVsYXRpb25faWQYBSABKAkSFgoOc291", @@ -53,42 +53,43 @@ namespace ScadaLink.Communication.Grpc { "bWVzc2FnZRgOIAEoCRIUCgxlcnJvcl9kZXRhaWwYDyABKAkSFwoPcmVxdWVz", "dF9zdW1tYXJ5GBAgASgJEhgKEHJlc3BvbnNlX3N1bW1hcnkYESABKAkSGQoR", "cGF5bG9hZF90cnVuY2F0ZWQYEiABKAgSDQoFZXh0cmEYEyABKAkSFAoMZXhl", - "Y3V0aW9uX2lkGBQgASgJIjwKD0F1ZGl0RXZlbnRCYXRjaBIpCgZldmVudHMY", - "ASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0RXZlbnREdG8iJwoJSW5nZXN0QWNr", - "EhoKEmFjY2VwdGVkX2V2ZW50X2lkcxgBIAMoCSL0AgoWU2l0ZUNhbGxPcGVy", - "YXRpb25hbER0bxIcChR0cmFja2VkX29wZXJhdGlvbl9pZBgBIAEoCRIPCgdj", - "aGFubmVsGAIgASgJEg4KBnRhcmdldBgDIAEoCRITCgtzb3VyY2Vfc2l0ZRgE", - "IAEoCRIOCgZzdGF0dXMYBSABKAkSEwoLcmV0cnlfY291bnQYBiABKAUSEgoK", - "bGFzdF9lcnJvchgHIAEoCRIwCgtodHRwX3N0YXR1cxgIIAEoCzIbLmdvb2ds", - "ZS5wcm90b2J1Zi5JbnQzMlZhbHVlEjIKDmNyZWF0ZWRfYXRfdXRjGAkgASgL", - "MhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIyCg51cGRhdGVkX2F0X3V0", - "YxgKIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASMwoPdGVybWlu", - "YWxfYXRfdXRjGAsgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKA", - "AQoVQ2FjaGVkVGVsZW1ldHJ5UGFja2V0Ei4KC2F1ZGl0X2V2ZW50GAEgASgL", - "Mhkuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50RHRvEjcKC29wZXJhdGlvbmFsGAIg", - "ASgLMiIuc2l0ZXN0cmVhbS5TaXRlQ2FsbE9wZXJhdGlvbmFsRHRvIkoKFENh", - "Y2hlZFRlbGVtZXRyeUJhdGNoEjIKB3BhY2tldHMYASADKAsyIS5zaXRlc3Ry", - "ZWFtLkNhY2hlZFRlbGVtZXRyeVBhY2tldCJbChZQdWxsQXVkaXRFdmVudHNS", - "ZXF1ZXN0Ei0KCXNpbmNlX3V0YxgBIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5U", - "aW1lc3RhbXASEgoKYmF0Y2hfc2l6ZRgCIAEoBSJcChdQdWxsQXVkaXRFdmVu", - "dHNSZXNwb25zZRIpCgZldmVudHMYASADKAsyGS5zaXRlc3RyZWFtLkF1ZGl0", - "RXZlbnREdG8SFgoObW9yZV9hdmFpbGFibGUYAiABKAgqXAoHUXVhbGl0eRIX", - "ChNRVUFMSVRZX1VOU1BFQ0lGSUVEEAASEAoMUVVBTElUWV9HT09EEAESFQoR", - "UVVBTElUWV9VTkNFUlRBSU4QAhIPCgtRVUFMSVRZX0JBRBADKl0KDkFsYXJt", - "U3RhdGVFbnVtEhsKF0FMQVJNX1NUQVRFX1VOU1BFQ0lGSUVEEAASFgoSQUxB", - "Uk1fU1RBVEVfTk9STUFMEAESFgoSQUxBUk1fU1RBVEVfQUNUSVZFEAIqhQEK", - "DkFsYXJtTGV2ZWxFbnVtEhQKEEFMQVJNX0xFVkVMX05PTkUQABITCg9BTEFS", - "TV9MRVZFTF9MT1cQARIXChNBTEFSTV9MRVZFTF9MT1dfTE9XEAISFAoQQUxB", - "Uk1fTEVWRUxfSElHSBADEhkKFUFMQVJNX0xFVkVMX0hJR0hfSElHSBAEMuEC", - "ChFTaXRlU3RyZWFtU2VydmljZRJVChFTdWJzY3JpYmVJbnN0YW5jZRIhLnNp", - "dGVzdHJlYW0uSW5zdGFuY2VTdHJlYW1SZXF1ZXN0Ghsuc2l0ZXN0cmVhbS5T", - "aXRlU3RyZWFtRXZlbnQwARJHChFJbmdlc3RBdWRpdEV2ZW50cxIbLnNpdGVz", - "dHJlYW0uQXVkaXRFdmVudEJhdGNoGhUuc2l0ZXN0cmVhbS5Jbmdlc3RBY2sS", - "UAoVSW5nZXN0Q2FjaGVkVGVsZW1ldHJ5EiAuc2l0ZXN0cmVhbS5DYWNoZWRU", - "ZWxlbWV0cnlCYXRjaBoVLnNpdGVzdHJlYW0uSW5nZXN0QWNrEloKD1B1bGxB", - "dWRpdEV2ZW50cxIiLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVxdWVz", - "dBojLnNpdGVzdHJlYW0uUHVsbEF1ZGl0RXZlbnRzUmVzcG9uc2VCH6oCHFNj", - "YWRhTGluay5Db21tdW5pY2F0aW9uLkdycGNiBnByb3RvMw==")); + "Y3V0aW9uX2lkGBQgASgJEhsKE3BhcmVudF9leGVjdXRpb25faWQYFSABKAki", + "PAoPQXVkaXRFdmVudEJhdGNoEikKBmV2ZW50cxgBIAMoCzIZLnNpdGVzdHJl", + "YW0uQXVkaXRFdmVudER0byInCglJbmdlc3RBY2sSGgoSYWNjZXB0ZWRfZXZl", + "bnRfaWRzGAEgAygJIvQCChZTaXRlQ2FsbE9wZXJhdGlvbmFsRHRvEhwKFHRy", + "YWNrZWRfb3BlcmF0aW9uX2lkGAEgASgJEg8KB2NoYW5uZWwYAiABKAkSDgoG", + "dGFyZ2V0GAMgASgJEhMKC3NvdXJjZV9zaXRlGAQgASgJEg4KBnN0YXR1cxgF", + "IAEoCRITCgtyZXRyeV9jb3VudBgGIAEoBRISCgpsYXN0X2Vycm9yGAcgASgJ", + "EjAKC2h0dHBfc3RhdHVzGAggASgLMhsuZ29vZ2xlLnByb3RvYnVmLkludDMy", + "VmFsdWUSMgoOY3JlYXRlZF9hdF91dGMYCSABKAsyGi5nb29nbGUucHJvdG9i", + "dWYuVGltZXN0YW1wEjIKDnVwZGF0ZWRfYXRfdXRjGAogASgLMhouZ29vZ2xl", + "LnByb3RvYnVmLlRpbWVzdGFtcBIzCg90ZXJtaW5hbF9hdF91dGMYCyABKAsy", + "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIoABChVDYWNoZWRUZWxlbWV0", + "cnlQYWNrZXQSLgoLYXVkaXRfZXZlbnQYASABKAsyGS5zaXRlc3RyZWFtLkF1", + "ZGl0RXZlbnREdG8SNwoLb3BlcmF0aW9uYWwYAiABKAsyIi5zaXRlc3RyZWFt", + "LlNpdGVDYWxsT3BlcmF0aW9uYWxEdG8iSgoUQ2FjaGVkVGVsZW1ldHJ5QmF0", + "Y2gSMgoHcGFja2V0cxgBIAMoCzIhLnNpdGVzdHJlYW0uQ2FjaGVkVGVsZW1l", + "dHJ5UGFja2V0IlsKFlB1bGxBdWRpdEV2ZW50c1JlcXVlc3QSLQoJc2luY2Vf", + "dXRjGAEgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgpiYXRj", + "aF9zaXplGAIgASgFIlwKF1B1bGxBdWRpdEV2ZW50c1Jlc3BvbnNlEikKBmV2", + "ZW50cxgBIAMoCzIZLnNpdGVzdHJlYW0uQXVkaXRFdmVudER0bxIWCg5tb3Jl", + "X2F2YWlsYWJsZRgCIAEoCCpcCgdRdWFsaXR5EhcKE1FVQUxJVFlfVU5TUEVD", + "SUZJRUQQABIQCgxRVUFMSVRZX0dPT0QQARIVChFRVUFMSVRZX1VOQ0VSVEFJ", + "ThACEg8KC1FVQUxJVFlfQkFEEAMqXQoOQWxhcm1TdGF0ZUVudW0SGwoXQUxB", + "Uk1fU1RBVEVfVU5TUEVDSUZJRUQQABIWChJBTEFSTV9TVEFURV9OT1JNQUwQ", + "ARIWChJBTEFSTV9TVEFURV9BQ1RJVkUQAiqFAQoOQWxhcm1MZXZlbEVudW0S", + "FAoQQUxBUk1fTEVWRUxfTk9ORRAAEhMKD0FMQVJNX0xFVkVMX0xPVxABEhcK", + "E0FMQVJNX0xFVkVMX0xPV19MT1cQAhIUChBBTEFSTV9MRVZFTF9ISUdIEAMS", + "GQoVQUxBUk1fTEVWRUxfSElHSF9ISUdIEAQy4QIKEVNpdGVTdHJlYW1TZXJ2", + "aWNlElUKEVN1YnNjcmliZUluc3RhbmNlEiEuc2l0ZXN0cmVhbS5JbnN0YW5j", + "ZVN0cmVhbVJlcXVlc3QaGy5zaXRlc3RyZWFtLlNpdGVTdHJlYW1FdmVudDAB", + "EkcKEUluZ2VzdEF1ZGl0RXZlbnRzEhsuc2l0ZXN0cmVhbS5BdWRpdEV2ZW50", + "QmF0Y2gaFS5zaXRlc3RyZWFtLkluZ2VzdEFjaxJQChVJbmdlc3RDYWNoZWRU", + "ZWxlbWV0cnkSIC5zaXRlc3RyZWFtLkNhY2hlZFRlbGVtZXRyeUJhdGNoGhUu", + "c2l0ZXN0cmVhbS5Jbmdlc3RBY2sSWgoPUHVsbEF1ZGl0RXZlbnRzEiIuc2l0", + "ZXN0cmVhbS5QdWxsQXVkaXRFdmVudHNSZXF1ZXN0GiMuc2l0ZXN0cmVhbS5Q", + "dWxsQXVkaXRFdmVudHNSZXNwb25zZUIfqgIcU2NhZGFMaW5rLkNvbW11bmlj", + "YXRpb24uR3JwY2IGcHJvdG8z")); descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.WrappersReflection.Descriptor, }, new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ScadaLink.Communication.Grpc.Quality), typeof(global::ScadaLink.Communication.Grpc.AlarmStateEnum), typeof(global::ScadaLink.Communication.Grpc.AlarmLevelEnum), }, null, new pbr::GeneratedClrTypeInfo[] { @@ -96,7 +97,7 @@ namespace ScadaLink.Communication.Grpc { new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteStreamEvent), global::ScadaLink.Communication.Grpc.SiteStreamEvent.Parser, new[]{ "CorrelationId", "AttributeChanged", "AlarmChanged" }, new[]{ "Event" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AttributeValueUpdate), global::ScadaLink.Communication.Grpc.AttributeValueUpdate.Parser, new[]{ "InstanceUniqueName", "AttributePath", "AttributeName", "Value", "Quality", "Timestamp" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AlarmStateUpdate), global::ScadaLink.Communication.Grpc.AlarmStateUpdate.Parser, new[]{ "InstanceUniqueName", "AlarmName", "State", "Priority", "Timestamp", "Level", "Message" }, null, null, null, null), - new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId" }, null, null, null, null), + new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventDto), global::ScadaLink.Communication.Grpc.AuditEventDto.Parser, new[]{ "EventId", "OccurredAtUtc", "Channel", "Kind", "CorrelationId", "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", "ExecutionId", "ParentExecutionId" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.AuditEventBatch), global::ScadaLink.Communication.Grpc.AuditEventBatch.Parser, new[]{ "Events" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.IngestAck), global::ScadaLink.Communication.Grpc.IngestAck.Parser, new[]{ "AcceptedEventIds" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ScadaLink.Communication.Grpc.SiteCallOperationalDto), global::ScadaLink.Communication.Grpc.SiteCallOperationalDto.Parser, new[]{ "TrackedOperationId", "Channel", "Target", "SourceSite", "Status", "RetryCount", "LastError", "HttpStatus", "CreatedAtUtc", "UpdatedAtUtc", "TerminalAtUtc" }, null, null, null, null), @@ -1592,6 +1593,7 @@ namespace ScadaLink.Communication.Grpc { payloadTruncated_ = other.payloadTruncated_; extra_ = other.extra_; executionId_ = other.executionId_; + parentExecutionId_ = other.parentExecutionId_; _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); } @@ -1854,6 +1856,21 @@ namespace ScadaLink.Communication.Grpc { } } + /// Field number for the "parent_execution_id" field. + public const int ParentExecutionIdFieldNumber = 21; + private string parentExecutionId_ = ""; + /// + /// empty string represents null + /// + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] + [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] + public string ParentExecutionId { + get { return parentExecutionId_; } + set { + parentExecutionId_ = pb::ProtoPreconditions.CheckNotNull(value, "value"); + } + } + [global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] public override bool Equals(object other) { @@ -1889,6 +1906,7 @@ namespace ScadaLink.Communication.Grpc { if (PayloadTruncated != other.PayloadTruncated) return false; if (Extra != other.Extra) return false; if (ExecutionId != other.ExecutionId) return false; + if (ParentExecutionId != other.ParentExecutionId) return false; return Equals(_unknownFields, other._unknownFields); } @@ -1916,6 +1934,7 @@ namespace ScadaLink.Communication.Grpc { if (PayloadTruncated != false) hash ^= PayloadTruncated.GetHashCode(); if (Extra.Length != 0) hash ^= Extra.GetHashCode(); if (ExecutionId.Length != 0) hash ^= ExecutionId.GetHashCode(); + if (ParentExecutionId.Length != 0) hash ^= ParentExecutionId.GetHashCode(); if (_unknownFields != null) { hash ^= _unknownFields.GetHashCode(); } @@ -2012,6 +2031,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(162, 1); output.WriteString(ExecutionId); } + if (ParentExecutionId.Length != 0) { + output.WriteRawTag(170, 1); + output.WriteString(ParentExecutionId); + } if (_unknownFields != null) { _unknownFields.WriteTo(output); } @@ -2100,6 +2123,10 @@ namespace ScadaLink.Communication.Grpc { output.WriteRawTag(162, 1); output.WriteString(ExecutionId); } + if (ParentExecutionId.Length != 0) { + output.WriteRawTag(170, 1); + output.WriteString(ParentExecutionId); + } if (_unknownFields != null) { _unknownFields.WriteTo(ref output); } @@ -2170,6 +2197,9 @@ namespace ScadaLink.Communication.Grpc { if (ExecutionId.Length != 0) { size += 2 + pb::CodedOutputStream.ComputeStringSize(ExecutionId); } + if (ParentExecutionId.Length != 0) { + size += 2 + pb::CodedOutputStream.ComputeStringSize(ParentExecutionId); + } if (_unknownFields != null) { size += _unknownFields.CalculateSize(); } @@ -2249,6 +2279,9 @@ namespace ScadaLink.Communication.Grpc { if (other.ExecutionId.Length != 0) { ExecutionId = other.ExecutionId; } + if (other.ParentExecutionId.Length != 0) { + ParentExecutionId = other.ParentExecutionId; + } _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); } @@ -2357,6 +2390,10 @@ namespace ScadaLink.Communication.Grpc { ExecutionId = input.ReadString(); break; } + case 170: { + ParentExecutionId = input.ReadString(); + break; + } } } #endif @@ -2465,6 +2502,10 @@ namespace ScadaLink.Communication.Grpc { ExecutionId = input.ReadString(); break; } + case 170: { + ParentExecutionId = input.ReadString(); + break; + } } } } diff --git a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs index 13120e3..8f40ebe 100644 --- a/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs +++ b/tests/ScadaLink.AuditLog.Tests/Site/SqliteAuditWriterSchemaTests.cs @@ -43,9 +43,9 @@ public class SqliteAuditWriterSchemaTests } [Fact] - public void Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId() + public void Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId() { - var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_21Columns_And_PK_On_EventId)); + var (writer, dataSource) = CreateWriter(nameof(Opens_Creates_AuditLog_Table_With_22Columns_And_PK_On_EventId)); using (writer) { using var connection = OpenVerifierConnection(dataSource); @@ -59,7 +59,7 @@ public class SqliteAuditWriterSchemaTests columns.Add((reader.GetString(1), reader.GetInt32(5))); } - Assert.Equal(21, columns.Count); + Assert.Equal(22, columns.Count); var expected = new[] { @@ -67,7 +67,7 @@ public class SqliteAuditWriterSchemaTests "SourceSiteId", "SourceInstanceId", "SourceScript", "Actor", "Target", "Status", "HttpStatus", "DurationMs", "ErrorMessage", "ErrorDetail", "RequestSummary", "ResponseSummary", "PayloadTruncated", "Extra", - "ForwardState", "ExecutionId", + "ForwardState", "ExecutionId", "ParentExecutionId", }; Assert.Equal(expected.OrderBy(n => n), columns.Select(c => c.Name).OrderBy(n => n)); @@ -245,4 +245,136 @@ public class SqliteAuditWriterSchemaTests Assert.True(ColumnExists(seedConnection, "ExecutionId")); } } + + // ----- ParentExecutionId schema-upgrade regression (persistent auditlog.db) ----- // + + /// + /// The pre-ParentExecutionId-branch AuditLog schema — the 21-column + /// CREATE TABLE that HAS ExecutionId but is WITHOUT + /// ParentExecutionId. A deployment that ran the ExecutionId branch + /// already has an on-disk auditlog.db in exactly this shape, and + /// CREATE TABLE IF NOT EXISTS is a no-op against it. + /// + private const string OldPreParentExecutionIdSchema = """ + CREATE TABLE IF NOT EXISTS AuditLog ( + EventId TEXT NOT NULL, + OccurredAtUtc TEXT NOT NULL, + Channel TEXT NOT NULL, + Kind TEXT NOT NULL, + CorrelationId TEXT NULL, + SourceSiteId TEXT NULL, + SourceInstanceId TEXT NULL, + SourceScript TEXT NULL, + Actor TEXT NULL, + Target TEXT NULL, + Status TEXT NOT NULL, + HttpStatus INTEGER NULL, + DurationMs INTEGER NULL, + ErrorMessage TEXT NULL, + ErrorDetail TEXT NULL, + RequestSummary TEXT NULL, + ResponseSummary TEXT NULL, + PayloadTruncated INTEGER NOT NULL, + Extra TEXT NULL, + ForwardState TEXT NOT NULL, + ExecutionId TEXT NULL, + PRIMARY KEY (EventId) + ); + CREATE INDEX IF NOT EXISTS IX_SiteAuditLog_ForwardState_Occurred + ON AuditLog (ForwardState, OccurredAtUtc); + """; + + /// + /// Seeds a shared-cache in-memory database with the pre-ParentExecutionId + /// 21-column schema and returns the open connection. The connection MUST + /// stay open for the lifetime of the test — a shared-cache in-memory + /// database is dropped once its last connection closes. + /// + private static SqliteConnection SeedPreParentExecutionIdSchemaDatabase(string dataSource) + { + var connection = new SqliteConnection($"Data Source={dataSource};Cache=Shared"); + connection.Open(); + using var cmd = connection.CreateCommand(); + cmd.CommandText = OldPreParentExecutionIdSchema; + cmd.ExecuteNonQuery(); + return connection; + } + + [Fact] + public async Task Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips() + { + var dataSource = $"file:{nameof(Opening_Over_PreExisting_PreParentExecutionId_Db_Adds_ParentExecutionId_Column_And_WriteAsync_RoundTrips)}-{Guid.NewGuid():N}?mode=memory&cache=shared"; + + // A deployment that ran the ExecutionId branch: auditlog.db already + // exists with the 21-column schema and NO ParentExecutionId column. + using var seedConnection = SeedPreParentExecutionIdSchemaDatabase(dataSource); + Assert.True(ColumnExists(seedConnection, "ExecutionId")); + Assert.False(ColumnExists(seedConnection, "ParentExecutionId")); + + // Upgrade: a post-branch SqliteAuditWriter opens the same database. Its + // InitializeSchema must ALTER the missing ParentExecutionId column in — + // the CREATE TABLE IF NOT EXISTS alone is a no-op against the existing + // table. + var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); + await using (var writer = CreateWriterOver(dataSource)) + { + Assert.True( + ColumnExists(seedConnection, "ParentExecutionId"), + "SqliteAuditWriter must ALTER the ParentExecutionId column into a pre-existing AuditLog table."); + + // A WriteAsync binding $ParentExecutionId must now succeed and + // round-trip; without the ALTER it would fail with "no such column: + // ParentExecutionId" and — because audit writes are best-effort — + // silently drop the row. + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.ApiOutbound, + Kind = AuditKind.ApiCall, + Status = AuditStatus.Delivered, + PayloadTruncated = false, + ExecutionId = executionId, + ParentExecutionId = parentExecutionId, + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Equal(executionId, row.ExecutionId); + Assert.Equal(parentExecutionId, row.ParentExecutionId); + } + + // Idempotency: a second writer over the now-upgraded DB must not error + // (the probe sees ParentExecutionId already present and skips the ALTER). + await using (var writerAgain = CreateWriterOver(dataSource)) + { + Assert.True(ColumnExists(seedConnection, "ParentExecutionId")); + } + } + + [Fact] + public async Task WriteAsync_NullParentExecutionId_RoundTripsAsNull() + { + var (writer, _) = CreateWriter(nameof(WriteAsync_NullParentExecutionId_RoundTripsAsNull)); + await using (writer) + { + var evt = new AuditEvent + { + EventId = Guid.NewGuid(), + OccurredAtUtc = DateTime.UtcNow, + Channel = AuditChannel.Notification, + Kind = AuditKind.NotifySend, + Status = AuditStatus.Submitted, + PayloadTruncated = false, + // ParentExecutionId left null + }; + await writer.WriteAsync(evt); + + var rows = await writer.ReadPendingAsync(limit: 10); + var row = Assert.Single(rows); + Assert.Null(row.ParentExecutionId); + } + } } diff --git a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs index c741855..06d1239 100644 --- a/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs +++ b/tests/ScadaLink.Communication.Tests/AuditEventDtoMapperTests.cs @@ -20,6 +20,7 @@ public class AuditEventDtoMapperTests var ingestedAt = new DateTime(2026, 5, 20, 10, 15, 31, 0, DateTimeKind.Utc); var correlationId = Guid.NewGuid(); var executionId = Guid.NewGuid(); + var parentExecutionId = Guid.NewGuid(); var eventId = Guid.NewGuid(); var original = new AuditEvent @@ -31,6 +32,7 @@ public class AuditEventDtoMapperTests Kind = AuditKind.ApiCallCached, CorrelationId = correlationId, ExecutionId = executionId, + ParentExecutionId = parentExecutionId, SourceSiteId = "site-1", SourceInstanceId = "Pump01", SourceScript = "OnDemand", @@ -57,6 +59,7 @@ public class AuditEventDtoMapperTests Assert.Equal(original.Kind, roundTripped.Kind); Assert.Equal(original.CorrelationId, roundTripped.CorrelationId); Assert.Equal(original.ExecutionId, roundTripped.ExecutionId); + Assert.Equal(original.ParentExecutionId, roundTripped.ParentExecutionId); Assert.Equal(original.SourceSiteId, roundTripped.SourceSiteId); Assert.Equal(original.SourceInstanceId, roundTripped.SourceInstanceId); Assert.Equal(original.SourceScript, roundTripped.SourceScript); @@ -94,6 +97,7 @@ public class AuditEventDtoMapperTests Assert.Equal(string.Empty, dto.CorrelationId); Assert.Equal(string.Empty, dto.ExecutionId); + Assert.Equal(string.Empty, dto.ParentExecutionId); Assert.Equal(string.Empty, dto.SourceSiteId); Assert.Equal(string.Empty, dto.SourceInstanceId); Assert.Equal(string.Empty, dto.SourceScript); @@ -118,6 +122,7 @@ public class AuditEventDtoMapperTests Status = nameof(AuditStatus.Submitted), CorrelationId = string.Empty, ExecutionId = string.Empty, + ParentExecutionId = string.Empty, SourceSiteId = string.Empty, SourceInstanceId = string.Empty, SourceScript = string.Empty, @@ -134,6 +139,7 @@ public class AuditEventDtoMapperTests Assert.Null(evt.CorrelationId); Assert.Null(evt.ExecutionId); + Assert.Null(evt.ParentExecutionId); Assert.Null(evt.SourceSiteId); Assert.Null(evt.SourceInstanceId); Assert.Null(evt.SourceScript);