diff --git a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs index ea6d17e2..1c85e604 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.Transport.Tests/Import/BundleSessionObservabilityTests.cs @@ -215,30 +215,39 @@ public sealed class BundleSessionObservabilityTests public async Task EvictionService_RegistersTheGaugeProvider_AndClearsItOnStop() { // The eviction service is Transport's only lifecycle seam, so it owns the - // process-global gauge slot. The gauge is read as a delta against a baseline - // because the instrument is a process-wide static. + // process-global gauge slot. Every assertion here is a DELTA over a scoped action + // against this test's own store (register row 74) — see ReadGaugeDelta. var clock = new TestClock(DateTimeOffset.UtcNow); var store = new BundleSessionStore(Options.Create(new TransportOptions()), clock, NullLogger.Instance); var service = new BundleSessionEvictionService(store, NullLogger.Instance); - var baseline = ReadGauge(); - await service.StartAsync(CancellationToken.None); try { - store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); - store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + // While registered, this store's opens are what the gauge reports. + var whileRegistered = ReadGaugeDelta(() => + { + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); + }); - Assert.Equal(2, ReadGauge()); + Assert.Equal(2, whileRegistered); } finally { await service.StopAsync(CancellationToken.None); } - // After the clear the gauge falls back to reporting 0, not through a dead store. - Assert.Equal(0, ReadGauge()); - Assert.Equal(0, baseline); + // After the clear the gauge no longer reads through this store: opening more + // sessions on it moves the instrument by nothing. Stated as a delta this is a + // STRONGER claim than the old `Assert.Equal(0, ReadGauge())` — that one merely + // said "the instrument reports zero", which any unregistered slot satisfies, + // and which a foreign registrant would have failed for reasons unrelated to + // the clear. + var afterClear = ReadGaugeDelta(() => + store.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30)))); + + Assert.Equal(0, afterClear); } [Fact] @@ -257,29 +266,81 @@ public sealed class BundleSessionObservabilityTests await newService.StartAsync(CancellationToken.None); try { - newStore.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30))); - - // Late stop of the superseded instance. + // Late stop of the superseded instance, then prove the NEWER store is still + // the one driving the gauge — by moving it and measuring the movement. await oldService.StopAsync(CancellationToken.None); - Assert.Equal(1, ReadGauge()); + var newStoreStillDrivesIt = ReadGaugeDelta(() => + newStore.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30)))); + + Assert.Equal(1, newStoreStillDrivesIt); + + // Negative control on the same window: the superseded store is detached, so + // its opens must not reach the instrument. Without this, a provider slot left + // pointing at oldStore would still satisfy the assertion above by coincidence + // whenever both stores happened to hold equal counts. + var oldStoreIsDetached = ReadGaugeDelta(() => + oldStore.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30)))); + + Assert.Equal(0, oldStoreIsDetached); } finally { await newService.StopAsync(CancellationToken.None); } - Assert.Equal(0, ReadGauge()); + var afterClear = ReadGaugeDelta(() => + newStore.Open(SessionExpiringAt(clock.GetUtcNow().AddMinutes(30)))); + + Assert.Equal(0, afterClear); } // ------------------------------------------------------------------ // Helpers // ------------------------------------------------------------------ + /// + /// Runs between two observations of the open-import-sessions + /// gauge and returns how far the instrument MOVED across it. + /// + /// + /// + /// Register row 74. These tests used to assert absolute gauge values — + /// Assert.Equal(0, baseline), Assert.Equal(2, ReadGauge()) — against + /// what is a single process-global provider slot + /// (SetImportSessionCountProvider / ClearImportSessionCountProvider). + /// That is the same shape as the register row 32 flake in + /// QueueDepthGaugeTests: correct only for as long as this file is the slot's + /// sole registrant, and silently wrong the day a sibling suite writes it. A delta + /// carries no assumption about what the slot held on entry, so a non-zero inherited + /// baseline cancels out. + /// + /// + /// Honest residual, and the reason this is a delta rather than a full fix: unlike + /// — where every measurement in the window is + /// driven synchronously by this test's own Open call, making it genuinely + /// immune — a gauge has ONE slot and last-writer-wins. If a concurrent registrant + /// overwrote the slot BETWEEN the two reads, no read-side technique could recover; + /// that case needs mutual exclusion on the slot (row 32's serialized collection). + /// It cannot arise today: this is the only file in the assembly that constructs a + /// BundleSessionEvictionService, and test assemblies run in separate + /// processes. A second such file should bring the collection treatment with it. + /// + /// + /// The action to measure across. + /// The gauge's movement while ran. + private static long ReadGaugeDelta(Action act) + { + var before = ReadGauge(); + act(); + return ReadGauge() - before; + } + /// /// Forces one synchronous observation of the open-import-sessions gauge and returns /// the measured value. Mirrors the MeterListener helper used by the Communication - /// and StoreAndForward telemetry suites. + /// and StoreAndForward telemetry suites. Private to by + /// convention — see its remarks for why no test reads this absolutely. /// /// The gauge's current value, or 0 if it never reported. private static long ReadGauge()