using System.Diagnostics; using ZB.MOM.WW.ScadaBridge.Commons.Types.Enums; namespace ZB.MOM.WW.ScadaBridge.LoadHarness.Scenarios; /// Result of one store-and-forward drain measurement. /// Site the buffer belonged to. /// Messages buffered before the drain began. /// Wall time to buffer them (concurrent, many origin instances). /// Buffering throughput. /// /// Wall time from the first sweep to the first successful delivery. With /// attemptImmediateDelivery: false the engine stamps LastAttemptAt, so the /// row is not due until one DefaultRetryInterval (30 s) has passed — this is the /// configured retry latency, not drain slowness, and is reported separately for that reason. /// /// Wall time from the first sweep to an empty buffer (includes the retry wait). /// /// Drain throughput measured from the FIRST delivery to an empty buffer — the engine's /// actual capacity, and the headline number for register row 50. /// /// Buffer depth left when the measurement stopped (0 = fully drained). /// Delivered-count samples during the drain, so a steady rate can be told from a stall-then-burst. public sealed record StoreAndForwardDrainResult( string SiteId, int MessageCount, double EnqueueSeconds, double EnqueuePerSecond, double TimeToFirstDeliverySeconds, double DrainSeconds, double DrainPerSecond, int ResidualDepth, IReadOnlyList Progress); /// One observation during the drain. /// Seconds since the drain began. /// Cumulative successful deliveries. /// Remaining buffer depth. public sealed record DrainProgressSample(double ElapsedSeconds, long Delivered, int Depth); /// /// Measures store-and-forward buffering and drain throughput (deferred-work register /// row 50, first half) using the real StoreAndForwardService, the real /// StoreAndForwardStorage and the real SQLite file — only the delivery target /// is a counting stub, because what is being measured is the site-local buffer's /// throughput, not a remote endpoint's. /// /// /// Phase 1 buffers messages with /// attemptImmediateDelivery: false, spread across many origin instance names /// and issued from many concurrent tasks — the "concurrent buffering from multiple /// instances" WP-4 asks about ([xc-7]). Phase 2 registers a delivery handler /// that always succeeds and drives sweeps to completion, timing the drain. /// /// /// The sweep is driven explicitly rather than waiting on the 10 s /// RetryTimerInterval so the number reported is the engine's drain capacity, /// not its polling cadence. The per-sweep batch is SweepBatchLimit (500) with /// SweepTargetParallelism (4) lanes, both at their production defaults. /// /// public static class StoreAndForwardDrainScenario { /// Runs the drain measurement against one site's real S&F engine. /// The site whose store-and-forward engine is exercised. /// Messages to buffer. /// Concurrent enqueue tasks (distinct origin instances). /// Cancels the measurement. /// The measured result. public static async Task RunAsync( SiteRuntimeFixture site, int messageCount, int concurrency, CancellationToken cancellationToken) { var service = site.StoreAndForward; var payload = $"{{\"site\":\"{site.SiteId}\",\"body\":\"{new string('x', 256)}\"}}"; // Phase 1 — concurrent buffering from many instances, no delivery attempted. var enqueueWatch = Stopwatch.StartNew(); var perTask = messageCount / concurrency; var enqueueTasks = new List(concurrency); for (var t = 0; t < concurrency; t++) { var taskIndex = t; enqueueTasks.Add(Task.Run(async () => { for (var i = 0; i < perTask; i++) { await service.EnqueueAsync( StoreAndForwardCategory.ExternalSystem, target: $"load-target-{taskIndex % 4}", payloadJson: payload, originInstanceName: site.InstanceName(taskIndex), attemptImmediateDelivery: false); } }, cancellationToken)); } await Task.WhenAll(enqueueTasks); enqueueWatch.Stop(); var buffered = perTask * concurrency; // Phase 2 — a delivery target that always succeeds; time the drain to empty. var delivered = 0L; service.RegisterDeliveryHandler(StoreAndForwardCategory.ExternalSystem, _ => { Interlocked.Increment(ref delivered); return Task.FromResult(true); }); // Sweeps are driven explicitly rather than waiting on the 10 s RetryTimerInterval: // the number wanted is the engine's drain CAPACITY, not its polling cadence. One // sweep moves at most SweepBatchLimit (500) messages, so a large backlog needs // many, and the progress series below is what distinguishes a genuinely slow // drain from an artefact of this polling loop. var drainWatch = Stopwatch.StartNew(); var deadline = DateTimeOffset.UtcNow.AddMinutes(10); var progress = new List(); int depth; while (true) { service.TriggerSweep(); await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); var depths = await service.GetBufferDepthAsync(); depth = depths.Values.Sum(); progress.Add(new DrainProgressSample( drainWatch.Elapsed.TotalSeconds, Interlocked.Read(ref delivered), depth)); if (depth == 0 || DateTimeOffset.UtcNow > deadline) break; } drainWatch.Stop(); // Split the retry wait from the drain: the first sample with a non-zero delivered // count marks the moment the backlog actually became due. var firstDelivery = progress.FirstOrDefault(s => s.Delivered > 0); var timeToFirstDelivery = firstDelivery?.ElapsedSeconds ?? drainWatch.Elapsed.TotalSeconds; var activeDrainSeconds = Math.Max(0.001, drainWatch.Elapsed.TotalSeconds - timeToFirstDelivery); return new StoreAndForwardDrainResult( SiteId: site.SiteId, MessageCount: buffered, EnqueueSeconds: enqueueWatch.Elapsed.TotalSeconds, EnqueuePerSecond: buffered / Math.Max(0.001, enqueueWatch.Elapsed.TotalSeconds), TimeToFirstDeliverySeconds: timeToFirstDelivery, DrainSeconds: drainWatch.Elapsed.TotalSeconds, DrainPerSecond: Interlocked.Read(ref delivered) / activeDrainSeconds, ResidualDepth: depth, Progress: progress); } }