f2efeb37b7
Tasks 5 and 6 of the Phase 2 plan, committed together because their test
fallout is entangled — several fixtures construct both stores.
StoreAndForwardStorage and SiteStorageService now take ILocalDb. Connections
come from ILocalDb.CreateConnection(), which hands out an already-open,
pragma-configured connection carrying the zb_hlc_next() UDF the capture triggers
call; a raw connection would lack the UDF and every write to a replicated table
would fail closed. Deleted with the connection strings: S&F's
EnsureDatabaseDirectoryExists and its per-open busy_timeout pragma, and the site
service's BusyTimeoutFloorSeconds normalization — LocalDb owns all of it now.
DI: AddSiteRuntime's string overload is gone (nothing left to supply), so the
Host calls the no-arg form. ScadaBridge:Database:SiteDbPath and
StoreAndForwardOptions.SqliteDbPath survive only as the migrator's source
locations in Tasks 8/9.
Two things the plan did not anticipate, both worth reading:
1. FOUND A REAL LATENT DEFECT, from Phase 1, now fixed. The plan assumed
directory creation simply moved to LocalDb along with file ownership. It did
not: the LocalDb library never creates the parent directory, and
SqliteLocalDb opens the file eagerly in its constructor — so a missing
directory is a hard boot failure ("SQLite Error 14: unable to open database
file"), not a degraded start. The default site config points at the RELATIVE
path ./data/site-localdb.db, so any site node without a pre-existing data/
directory fails to boot. The docker rig escapes only because its volume mount
happens to create /app/data — a coincidence that would have hidden this until
a bare-metal or fresh deployment. This has been latent since Phase 1 made
LocalDb:Path required; deleting S&F's EnsureDatabaseDirectoryExists here
would have widened it. Re-established the guarantee at the layer that now
owns the path (SiteLocalDbDirectory.Ensure, called before AddZbLocalDb) and
pinned it with SiteLocalDbDirectoryTests. Non-vacuity is not assumed: two
tests written against the wrong assumption failed with exactly this
SQLite Error 14 before the fix existed.
2. Test fallout was ~7x the plan's estimate. The plan named "fixtures" in one
project; the constructor change actually reaches 40 files across 7 test
projects, and most used Mode=Memory;Cache=Shared — which LocalDb has no
equivalent for, so every one had to move to a real temp file. Rather than
copy the Phase 1 TestLocalDb fixture into 7 projects, added a shared
tests/ZB.MOM.WW.ScadaBridge.TestSupport library (not a test project) so the
WAL-sidecar cleanup and the "real, not stubbed" rationale live in one place.
Retargeted rather than deleted, in both directions: the S&F WAL test now asserts
against the LocalDb-backed store (WAL genuinely is LocalDb's job), while the
directory-creation test moved to Host.Tests (that guarantee is NOT LocalDb's).
SiteStorageServiceTests.Initialize_EnablesWalJournalMode got the same treatment.
DeploymentManagerMediumFindingsTests induced a persistence failure via an
unopenable path, which no longer reaches the assertion since the fixture now
throws first; it induces the same failure shape via an uninitialized store.
Verified: full solution build 0 warnings; SiteRuntime 532, Host 318,
AuditLog 355, ExternalSystemGateway 142, HealthMonitoring 97,
StoreAndForward 153 — 1597 passed, 0 failed.
Claude-Session: https://claude.ai/code/session_01BL2Vu1ESDQ9SCN4gVKkdts
336 lines
14 KiB
C#
336 lines
14 KiB
C#
using System.Diagnostics.Metrics;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Observability;
|
|
using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums;
|
|
using ZB.MOM.WW.ScadaBridge.TestSupport;
|
|
|
|
namespace ZB.MOM.WW.ScadaBridge.StoreAndForward.Tests;
|
|
|
|
/// <summary>
|
|
/// WP-14 (telemetry follow-on): verifies the cached buffered-message counter that
|
|
/// backs the <c>scadabridge.store_and_forward.queue.depth</c> observable gauge tracks
|
|
/// the live (Pending) queue across the existing enqueue / drain / park / requeue paths,
|
|
/// and that the sync gauge callback reports it.
|
|
///
|
|
/// The gauge is read the way the OpenTelemetry collector reads it — via a
|
|
/// <see cref="MeterListener"/> that forces an observation (the callback is synchronous
|
|
/// and does no I/O, which is the whole point of caching the count). <see cref="StartAsync"/>
|
|
/// seeds the counter from storage and registers the provider against this service
|
|
/// instance, so the gauge resolves to this test's counter.
|
|
/// </summary>
|
|
public class QueueDepthGaugeTests : IAsyncLifetime, IDisposable
|
|
{
|
|
private readonly TestLocalDb _localDb;
|
|
private readonly StoreAndForwardStorage _storage;
|
|
private readonly StoreAndForwardService _service;
|
|
|
|
public QueueDepthGaugeTests()
|
|
{
|
|
_localDb = TestLocalDb.CreateTemp("QueueDepthTests");
|
|
|
|
_storage = new StoreAndForwardStorage(_localDb.Db, NullLogger<StoreAndForwardStorage>.Instance);
|
|
|
|
var options = new StoreAndForwardOptions
|
|
{
|
|
DefaultRetryInterval = TimeSpan.Zero,
|
|
DefaultMaxRetries = 3,
|
|
// Long interval so no background sweep fires on its own during the test;
|
|
// sweeps are driven explicitly via RetryPendingMessagesAsync.
|
|
RetryTimerInterval = TimeSpan.FromMinutes(10)
|
|
};
|
|
|
|
_service = new StoreAndForwardService(
|
|
_storage, options, NullLogger<StoreAndForwardService>.Instance);
|
|
}
|
|
|
|
public async Task InitializeAsync()
|
|
{
|
|
await _storage.InitializeAsync();
|
|
// StartAsync seeds _bufferedCount from the (empty) store and registers the
|
|
// queue-depth provider against this service instance.
|
|
await _service.StartAsync();
|
|
}
|
|
|
|
public async Task DisposeAsync() => await _service.StopAsync();
|
|
|
|
public void Dispose()
|
|
{
|
|
var path = _localDb.Path;
|
|
_localDb.Dispose();
|
|
TestLocalDb.DeleteFiles(path);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Reads the current value of the <c>scadabridge.store_and_forward.queue.depth</c>
|
|
/// gauge by forcing a synchronous observation through a transient MeterListener —
|
|
/// exactly the path the Prometheus/OTLP collector exercises on each scrape.
|
|
/// </summary>
|
|
private static long ReadQueueDepthGauge()
|
|
{
|
|
long observed = -1;
|
|
using var listener = new MeterListener
|
|
{
|
|
InstrumentPublished = (instrument, l) =>
|
|
{
|
|
if (instrument.Meter.Name == ScadaBridgeTelemetry.MeterName &&
|
|
instrument.Name == "scadabridge.store_and_forward.queue.depth")
|
|
{
|
|
l.EnableMeasurementEvents(instrument);
|
|
}
|
|
}
|
|
};
|
|
listener.SetMeasurementEventCallback<long>((_, measurement, _, _) => observed = measurement);
|
|
listener.Start();
|
|
listener.RecordObservableInstruments();
|
|
return observed;
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Gauge_TracksBufferedDepth_AcrossEnqueueDrainAndPark()
|
|
{
|
|
// Empty store seeded at StartAsync → gauge reports 0.
|
|
Assert.Equal(0, ReadQueueDepthGauge());
|
|
|
|
// A handler that fails transiently so each enqueue buffers a Pending row
|
|
// (immediate attempt 0 throws → BufferAsync → +1).
|
|
var deliver = false;
|
|
_service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem,
|
|
_ =>
|
|
{
|
|
if (!deliver) throw new HttpRequestException("transient");
|
|
return Task.FromResult(true);
|
|
});
|
|
|
|
// Enqueue 3 → cached depth = 3 → gauge reports 3.
|
|
for (var i = 0; i < 3; i++)
|
|
{
|
|
var r = await _service.EnqueueAsync(
|
|
StoreAndForwardCategory.ExternalSystem, "api", """{}""");
|
|
Assert.True(r.WasBuffered);
|
|
}
|
|
Assert.Equal(3, ReadQueueDepthGauge());
|
|
|
|
// Drain: handler now succeeds → the retry sweep removes all 3 Pending rows → depth 0.
|
|
deliver = true;
|
|
await _service.RetryPendingMessagesAsync();
|
|
Assert.Equal(0, ReadQueueDepthGauge());
|
|
|
|
// Park path: buffer one more, then make it park (maxRetries:1 parks after one
|
|
// sweep). Pending→Parked leaves the live queue → depth back to 0.
|
|
deliver = false;
|
|
var parkResult = await _service.EnqueueAsync(
|
|
StoreAndForwardCategory.ExternalSystem, "api", """{}""", maxRetries: 1);
|
|
Assert.True(parkResult.WasBuffered);
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
|
|
await _service.RetryPendingMessagesAsync();
|
|
var parked = await _storage.GetMessageByIdAsync(parkResult.MessageId);
|
|
Assert.Equal(StoreAndForwardMessageStatus.Parked, parked!.Status);
|
|
Assert.Equal(0, ReadQueueDepthGauge());
|
|
|
|
// Operator requeue: Parked→Pending re-adds to the live queue → depth 1.
|
|
Assert.True(await _service.RetryParkedMessageAsync(parkResult.MessageId));
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
}
|
|
|
|
/// <summary>
|
|
/// StoreAndForward-025: after a graceful <see cref="StoreAndForwardService.StopAsync"/>
|
|
/// the service must deregister its queue-depth provider from the process-global gauge
|
|
/// slot, so the gauge stops reporting the stopped instance's (now-frozen) depth and the
|
|
/// provider closure no longer pins the dead service. With the provider cleared the gauge
|
|
/// falls back to 0.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StopAsync_ClearsQueueDepthProvider_GaugeFallsBackToZero()
|
|
{
|
|
var fresh = new StoreAndForwardService(
|
|
_storage,
|
|
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
|
|
NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
// Register a Pending row this instance owns, then start so the instance registers
|
|
// its provider and seeds the cached count to 1 → gauge reports 1.
|
|
await _storage.EnqueueAsync(new StoreAndForwardMessage
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Category = StoreAndForwardCategory.ExternalSystem,
|
|
Target = "api",
|
|
PayloadJson = "{}",
|
|
Status = StoreAndForwardMessageStatus.Pending,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
MaxRetries = 3
|
|
});
|
|
await fresh.StartAsync();
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
|
|
// Graceful stop must deregister the provider; the gauge falls back to 0 rather
|
|
// than reporting this dead instance's frozen depth of 1.
|
|
await fresh.StopAsync();
|
|
Assert.Equal(0, ReadQueueDepthGauge());
|
|
}
|
|
|
|
/// <summary>
|
|
/// StoreAndForward-025 (compare-and-clear): when a newer instance has already
|
|
/// registered its provider into the process-global slot, a late
|
|
/// <see cref="StoreAndForwardService.StopAsync"/> of an older instance must NOT clear
|
|
/// the slot — the identity-checked clear only removes the slot when it still holds the
|
|
/// stopping instance's own delegate. After the late stop the gauge must still report
|
|
/// the newer instance's depth, not 0.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StopAsync_DoesNotClobberNewerInstanceProvider()
|
|
{
|
|
// Old instance: starts over an empty store, registers its provider (gauge → 0),
|
|
// then takes a single buffered message so it would report 1 if it stayed live.
|
|
var older = new StoreAndForwardService(
|
|
_storage,
|
|
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
|
|
NullLogger<StoreAndForwardService>.Instance);
|
|
await older.StartAsync();
|
|
older.TestOnly_IncrementBufferedCount(); // older's depth would be 1
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
|
|
// New instance: starts and re-registers into the same global slot, winning it.
|
|
// It seeds from the (empty) store and stands in two buffered messages → depth 2.
|
|
var newer = new StoreAndForwardService(
|
|
_storage,
|
|
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
|
|
NullLogger<StoreAndForwardService>.Instance);
|
|
await newer.StartAsync();
|
|
newer.TestOnly_IncrementBufferedCount();
|
|
newer.TestOnly_IncrementBufferedCount();
|
|
Assert.Equal(2, ReadQueueDepthGauge());
|
|
|
|
// Late stop of the OLDER instance: compare-and-clear must fail the identity check
|
|
// (the slot now holds the newer instance's delegate), so the newer provider stays.
|
|
await older.StopAsync();
|
|
Assert.Equal(2, ReadQueueDepthGauge());
|
|
|
|
// Cleanup: stop the newer instance, which legitimately clears its own provider.
|
|
await newer.StopAsync();
|
|
Assert.Equal(0, ReadQueueDepthGauge());
|
|
}
|
|
|
|
/// <summary>
|
|
/// StoreAndForward-028: a same-instance Stop→Start must re-seed the cached depth from a
|
|
/// clean base. <see cref="StoreAndForwardService.StopAsync"/> resets the one-time
|
|
/// registration guard so a later <see cref="StoreAndForwardService.StartAsync"/>
|
|
/// re-registers and re-seeds <c>_bufferedCount</c> from the durable Pending count; if
|
|
/// StopAsync did not also reset <c>_bufferedCount</c>, the restart would ADD the re-read
|
|
/// count on top of the leftover in-memory value, double-counting the gauge to ~2N.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task StartAsync_AfterStop_ReseedsFromCleanBase_NoDoubleCount()
|
|
{
|
|
// One durable Pending row that survives the stop/restart in SQLite.
|
|
await _storage.EnqueueAsync(new StoreAndForwardMessage
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Category = StoreAndForwardCategory.ExternalSystem,
|
|
Target = "api",
|
|
PayloadJson = "{}",
|
|
Status = StoreAndForwardMessageStatus.Pending,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
MaxRetries = 3
|
|
});
|
|
|
|
var svc = new StoreAndForwardService(
|
|
_storage,
|
|
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
|
|
NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
// First start seeds the cached count from the durable store → 1.
|
|
await svc.StartAsync();
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
|
|
// Graceful stop resets the registration guard AND the cached count (the fix).
|
|
await svc.StopAsync();
|
|
|
|
// Restart the SAME instance: the guard was reset so StartAsync re-seeds from the
|
|
// store (still 1 Pending). With the _bufferedCount reset the gauge reports 1, not 2;
|
|
// without it the seed would ADD onto the leftover 1 → 2 (the double-count bug).
|
|
await svc.StartAsync();
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
|
|
await svc.StopAsync();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Gauge_SeedsFromExistingPendingRows_OnStart()
|
|
{
|
|
// Pre-seed two Pending rows directly in storage *before* a fresh service starts,
|
|
// simulating a process restart over a non-empty buffer. StartAsync must seed the
|
|
// cached counter from the store so the gauge does not under-report on restart.
|
|
await _storage.EnqueueAsync(new StoreAndForwardMessage
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Category = StoreAndForwardCategory.ExternalSystem,
|
|
Target = "api",
|
|
PayloadJson = "{}",
|
|
Status = StoreAndForwardMessageStatus.Pending,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
MaxRetries = 3
|
|
});
|
|
await _storage.EnqueueAsync(new StoreAndForwardMessage
|
|
{
|
|
Id = Guid.NewGuid().ToString("N"),
|
|
Category = StoreAndForwardCategory.Notification,
|
|
Target = "list",
|
|
PayloadJson = "{}",
|
|
Status = StoreAndForwardMessageStatus.Pending,
|
|
CreatedAt = DateTimeOffset.UtcNow,
|
|
MaxRetries = 3
|
|
});
|
|
|
|
var fresh = new StoreAndForwardService(
|
|
_storage,
|
|
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
|
|
NullLogger<StoreAndForwardService>.Instance);
|
|
try
|
|
{
|
|
await fresh.StartAsync();
|
|
// The fresh service registered itself as the global provider and seeded 2.
|
|
Assert.Equal(2, ReadQueueDepthGauge());
|
|
}
|
|
finally
|
|
{
|
|
await fresh.StopAsync();
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Review finding (FINDING 1): the startup seed must ADD to whatever the counter
|
|
/// already holds, not overwrite it. A concurrent <c>BufferAsync</c> can
|
|
/// <c>Interlocked.Increment</c> <c>_bufferedCount</c> in the window between
|
|
/// <c>StartAsync</c>'s async <c>COUNT(*)</c> returning and the seed running; with an
|
|
/// <c>Interlocked.Exchange</c> seed that increment would be clobbered (lost +1). This
|
|
/// pre-increments the in-memory counter (standing in for that concurrent enqueue),
|
|
/// then starts the service over an empty store and asserts the pre-increment survives.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Gauge_SeedAddsToConcurrentPreSeedIncrement_NotClobber()
|
|
{
|
|
// Store is empty (StartAsync's pending COUNT(*) = 0), so the only contribution
|
|
// is the simulated concurrent pre-seed enqueue increment.
|
|
var fresh = new StoreAndForwardService(
|
|
_storage,
|
|
new StoreAndForwardOptions { RetryTimerInterval = TimeSpan.FromMinutes(10) },
|
|
NullLogger<StoreAndForwardService>.Instance);
|
|
|
|
// Stand in for a BufferAsync increment that landed before StartAsync seeded.
|
|
fresh.TestOnly_IncrementBufferedCount();
|
|
|
|
try
|
|
{
|
|
await fresh.StartAsync();
|
|
// Add(0 seed) over the pre-existing +1 → 1. An Exchange(0) seed would clobber
|
|
// it to 0, losing the concurrent enqueue — the bug this fix prevents.
|
|
Assert.Equal(1, ReadQueueDepthGauge());
|
|
}
|
|
finally
|
|
{
|
|
await fresh.StopAsync();
|
|
}
|
|
}
|
|
}
|