mirror of
https://github.com/okieselbach/AutopilotMonitor.git
synced 2026-10-25 21:19:38 +00:00
c3ee0cad55
Adds the async fan-out producer path from Plan §2.8 / §M5.d so primary Signals + DecisionTransitions rows can be projected onto their index tables without blocking the hot ingest loop. Components: - Constants.QueueNames.TelemetryIndexReconcile (azure-queue-name-safe, all lowercase + hyphens). - IndexReconcileEnvelope (Shared DTO, flat shape with nullable fields per SourceKind="Signal"/"DecisionTransition"). Avoids polymorphic Newtonsoft deserialization — see M5.c.3 finding. - IndexReconcileEnvelopeFactory pure static helper (FromSignal / FromDecisionTransition / BuildBatch) — state-less projection. - IIndexReconcileProducer (Shared interface) contract: no-op when the feature flag is off; never rethrows underlying-queue failures. - AzureQueueIndexReconcileProducer (Functions) — Base64-encoded JSON messages, lazy CreateIfNotExistsAsync guarded by Interlocked flag, Managed-Identity-or-connection-string construction mirroring BlobStorageService / TableStorageService. - IngestTelemetryFunction ctor gains IIndexReconcileProducer; after primary Signals + DecisionTransitions commit it builds envelopes and enqueues them. Event path is untouched (events don't feed the index tables per §2.8). - Program.cs registers the producer as a singleton. Feature gate (AdminConfiguration.EnableIndexDualWrite, default false): - New bool field in AdminConfiguration. - TableConfigRepository Store + Map extension (per feedback_table_storage_serialization). - Producer checks config each call via AdminConfigurationService's 5-min memory cache. Flag off → primary behaviour is bit-exact. Fault tolerance: queue-side exceptions (network, throttling, missing queue before first CreateIfNotExists) are logged per envelope and swallowed. The primary row is the source of truth; the 2h reconcile timer (M5.d.4) is the safety-net recovery path. Tests (+11 in Functions): - 5 IndexReconcileEnvelopeFactoryTests: Signal/DecisionTransition projection, dead-end preservation, batch ordering, null/empty. - 3 IndexReconcileEnvelopeSerializationTests: Newtonsoft round-trip (Signal, full DecisionTransition, minimal-envelope tolerance). - 3 AdminConfigurationEnableIndexDualWriteTests: default-false on new config + CreateDefault, persists when set. Deferred to later sub-milestones: - M5.d.3: queue-triggered consumer that maps envelopes to 0–3 index rows using M5.d.1 TableIndexRepository. - M5.d.4: 2h reconcile timer + release-gate dual-write verification tests, then flip EnableIndexDualWrite=true in prod admin config. Full Functions suite: 672 green (prev 661 + 11 new). Full solution build green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
103 lines
4.4 KiB
C#
103 lines
4.4 KiB
C#
using AutopilotMonitor.Shared.Models;
|
|
using Newtonsoft.Json;
|
|
|
|
namespace AutopilotMonitor.Functions.Tests;
|
|
|
|
/// <summary>
|
|
/// JSON round-trip tests for <see cref="IndexReconcileEnvelope"/> (Plan §2.8, §M5.d).
|
|
/// The producer (M5.d.2) serializes with Newtonsoft; the queue-triggered consumer
|
|
/// (M5.d.3) must deserialize the same shape back — verified here so the contract
|
|
/// doesn't drift silently as fields are added.
|
|
/// </summary>
|
|
public class IndexReconcileEnvelopeSerializationTests
|
|
{
|
|
private const string TenantId = "a1b2c3d4-e5f6-7890-abcd-ef1234567890";
|
|
private const string SessionId = "b2c3d4e5-f6a7-8901-bcde-f12345678901";
|
|
|
|
private static readonly DateTime Occurred =
|
|
new(2026, 4, 21, 14, 7, 33, DateTimeKind.Utc);
|
|
|
|
[Fact]
|
|
public void Signal_envelope_round_trips_through_newtonsoft()
|
|
{
|
|
var original = new IndexReconcileEnvelope
|
|
{
|
|
EnvelopeVersion = "1",
|
|
SourceKind = "Signal",
|
|
TenantId = TenantId,
|
|
SessionId = SessionId,
|
|
OccurredAtUtc = Occurred,
|
|
SessionSignalOrdinal = 42,
|
|
SignalKind = "EspTerminalFailure",
|
|
SourceOrigin = "EspAndHelloTrackerAdapter",
|
|
};
|
|
|
|
var json = JsonConvert.SerializeObject(original);
|
|
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
|
|
|
|
Assert.NotNull(restored);
|
|
Assert.Equal("1", restored!.EnvelopeVersion);
|
|
Assert.Equal("Signal", restored.SourceKind);
|
|
Assert.Equal(TenantId, restored.TenantId);
|
|
Assert.Equal(SessionId, restored.SessionId);
|
|
Assert.Equal(Occurred, restored.OccurredAtUtc.ToUniversalTime());
|
|
Assert.Equal(42L, restored.SessionSignalOrdinal);
|
|
Assert.Equal("EspTerminalFailure", restored.SignalKind);
|
|
Assert.Equal("EspAndHelloTrackerAdapter", restored.SourceOrigin);
|
|
Assert.Null(restored.StepIndex);
|
|
Assert.Null(restored.ClassifierVerdictId);
|
|
}
|
|
|
|
[Fact]
|
|
public void DecisionTransition_envelope_round_trips_all_discriminators()
|
|
{
|
|
var original = new IndexReconcileEnvelope
|
|
{
|
|
EnvelopeVersion = "1",
|
|
SourceKind = "DecisionTransition",
|
|
TenantId = TenantId,
|
|
SessionId = SessionId,
|
|
OccurredAtUtc = Occurred,
|
|
StepIndex = 7,
|
|
FromStage = "EspInProgress",
|
|
ToStage = "WhiteGloveSealed",
|
|
Taken = true,
|
|
IsTerminal = true,
|
|
DeadEndReason = null,
|
|
ClassifierVerdictId = "whiteglove-sealing",
|
|
ClassifierHypothesisLevel = "Strong",
|
|
};
|
|
|
|
var json = JsonConvert.SerializeObject(original);
|
|
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(json);
|
|
|
|
Assert.NotNull(restored);
|
|
Assert.Equal("DecisionTransition", restored!.SourceKind);
|
|
Assert.Equal(7, restored.StepIndex);
|
|
Assert.Equal("EspInProgress", restored.FromStage);
|
|
Assert.Equal("WhiteGloveSealed", restored.ToStage);
|
|
Assert.Equal(true, restored.Taken);
|
|
Assert.Equal(true, restored.IsTerminal);
|
|
Assert.Null(restored.DeadEndReason);
|
|
Assert.Equal("whiteglove-sealing", restored.ClassifierVerdictId);
|
|
Assert.Equal("Strong", restored.ClassifierHypothesisLevel);
|
|
Assert.Null(restored.SessionSignalOrdinal);
|
|
}
|
|
|
|
[Fact]
|
|
public void Deserialization_tolerates_missing_optional_fields()
|
|
{
|
|
// A minimal envelope from a consumer-compatible-but-older writer should still parse.
|
|
const string minimal =
|
|
"{\"envelopeVersion\":\"1\",\"sourceKind\":\"Signal\",\"tenantId\":\"t\",\"sessionId\":\"s\",\"occurredAtUtc\":\"2026-04-21T14:07:33Z\"}";
|
|
|
|
var restored = JsonConvert.DeserializeObject<IndexReconcileEnvelope>(minimal);
|
|
|
|
Assert.NotNull(restored);
|
|
Assert.Equal("Signal", restored!.SourceKind);
|
|
Assert.Null(restored.SessionSignalOrdinal);
|
|
Assert.Null(restored.SignalKind);
|
|
Assert.Null(restored.StepIndex);
|
|
}
|
|
}
|