diff --git a/Pulse.Tests/DutyCycleTests.cs b/Pulse.Tests/DutyCycleTests.cs new file mode 100644 index 0000000..479eaf4 --- /dev/null +++ b/Pulse.Tests/DutyCycleTests.cs @@ -0,0 +1,199 @@ +using Xunit; + +namespace Pulse.Tests; + +/// The schedule alone: when a burst starts, how many ticks it lasts, when it ends, and +/// what a switch or a reload does to one in progress. No profiler, no trees and no owners: +/// TickAttributionTests covers what attribution does with each step. +public class DutyCycleTests +{ + /// Ticks the cycle times and returns what each tick was. + private static DutyStep[] Run(DutyCycle cycle, int ticks, double elapsedSeconds = 1.0) + { + DutyStep[] steps = new DutyStep[ticks]; + for (int tick = 0; tick < ticks; tick++) + { + steps[tick] = cycle.OnTick(elapsedSeconds); + } + + return steps; + } + + [Fact] + public void Constructor_Floors_TheIntervalAndTheBurstLength() + { + DutyCycle cycle = new(0, 0); + + Assert.Equal(1, cycle.BurstTicks); + Assert.Equal(1, cycle.IntervalSeconds); + } + + [Fact] + public void Constructor_Caps_TheBurstLength() + => Assert.Equal(300, new DutyCycle(100000, 10).BurstTicks); + + [Fact] + public void Constructor_Keeps_AConfiguredDutyCycle() + { + DutyCycle cycle = new(30, 10); + + Assert.True(cycle.Enabled); + Assert.Equal(30, cycle.BurstTicks); + Assert.Equal(10, cycle.IntervalSeconds); + } + + [Fact] + public void OnTick_StaysIdle_UntilTheIntervalHasPassed() + { + DutyCycle cycle = new(5, 10); + + for (int tick = 0; tick < 9; tick++) + { + Assert.Equal(DutyStep.Idle, cycle.OnTick(1.0)); + Assert.False(cycle.InBurst); + } + + Assert.Equal(DutyStep.Start, cycle.OnTick(1.0)); + Assert.True(cycle.InBurst); + } + + /// The interval is seconds, not ticks: half-second ticks take twice as many to cross it, + /// and the burst starts on the one that lands exactly on it. + [Fact] + public void OnTick_CountsTheInterval_InSeconds() + => Assert.Equal( + [DutyStep.Idle, DutyStep.Idle, DutyStep.Idle, DutyStep.Start], + Run(new DutyCycle(5, 2), 4, 0.5)); + + /// Whatever the caller switches on at the start is switched on part-way through that + /// tick, so what it reads on the next one describes a tick only partly covered. That one is the + /// warm-up, and it is not a sample. + [Fact] + public void OnTick_WarmsUp_OnTheTickAfterTheBurstStarts() + { + DutyCycle cycle = new(1, 1); + + Assert.Equal(DutyStep.Start, cycle.OnTick(1.0)); + Assert.Equal(DutyStep.WarmUp, cycle.OnTick(1.0)); + Assert.True(cycle.InBurst); + Assert.Equal(DutyStep.LastSample, cycle.OnTick(1.0)); + } + + [Fact] + public void OnTick_Takes_BurstTicksSamples_ThenEndsTheBurst() + { + DutyCycle cycle = new(3, 1); + + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.Sample], Run(cycle, 4)); + Assert.True(cycle.InBurst); + + Assert.Equal(DutyStep.LastSample, cycle.OnTick(1.0)); + Assert.False(cycle.InBurst); + } + + /// The interval counts from the end of a burst, and the next burst has the same shape + /// as the first: its own warm-up, then its own samples. + [Fact] + public void OnTick_Runs_ASecondBurstAfterTheNextInterval() + { + DutyCycle cycle = new(2, 2); + DutyStep[] oneBurst = [DutyStep.Idle, DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample]; + + Assert.Equal(oneBurst, Run(cycle, 5)); + Assert.Equal(oneBurst, Run(cycle, 5)); + } + + [Fact] + public void OnTick_StaysIdle_WhileDisabled() + { + DutyCycle cycle = new(1, 1, enabled: false); + + Assert.All(Run(cycle, 100), step => Assert.Equal(DutyStep.Idle, step)); + Assert.False(cycle.Enabled); + Assert.False(cycle.InBurst); + } + + /// The whole point of arming a cycle on a server that did not ask for it: it can be + /// switched on later, and then it runs exactly as if the config had said so. + [Fact] + public void Apply_Starts_TheCycle_OnAServerThatBootedWithItOff() + { + DutyCycle cycle = new(2, 1, enabled: false); + cycle.OnTick(1.0); + + cycle.Apply(true, 2, 1); + + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample], Run(cycle, 4)); + } + + /// Switching it off part-way through a burst drops the burst instead of finishing it: + /// the cycle is out of the burst at once, and no later tick is a sample. + [Fact] + public void Apply_Drops_ABurstInProgress_WhenItIsSwitchedOff() + { + DutyCycle cycle = new(30, 1); + Run(cycle, 5); + Assert.True(cycle.InBurst); + + cycle.Apply(false, 30, 1); + + Assert.False(cycle.Enabled); + Assert.False(cycle.InBurst); + Assert.All(Run(cycle, 100), step => Assert.Equal(DutyStep.Idle, step)); + Assert.False(cycle.InBurst); + } + + /// A reload that leaves the cycle on drops the burst in progress the same way, and the + /// burst length it brings is the one the next burst runs. + [Fact] + public void Apply_Drops_ABurstInProgress_WhenItIsReapplied() + { + DutyCycle cycle = new(30, 1); + Run(cycle, 5); + Assert.True(cycle.InBurst); + + cycle.Apply(true, 2, 1); + + Assert.False(cycle.InBurst); + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample], Run(cycle, 4)); + } + + /// A new interval is counted from the reload, not from whatever the old one had already + /// waited. + [Fact] + public void Apply_Restarts_TheInterval_OfARunningCycle() + { + DutyCycle cycle = new(5, 10); + Run(cycle, 6); + + cycle.Apply(true, 5, 10); + + Assert.All(Run(cycle, 9), step => Assert.Equal(DutyStep.Idle, step)); + Assert.Equal(DutyStep.Start, cycle.OnTick(1.0)); + } + + /// Switching back on starts a fresh cycle, so the burst begins with its warm-up again + /// rather than carrying on from where the old one stopped. + [Fact] + public void Apply_WarmsUpAgain_WhenItIsSwitchedBackOn() + { + DutyCycle cycle = new(2, 1); + Run(cycle, 3); + cycle.Apply(false, 2, 1); + + cycle.Apply(true, 2, 1); + + Assert.Equal([DutyStep.Start, DutyStep.WarmUp, DutyStep.Sample, DutyStep.LastSample], Run(cycle, 4)); + } + + [Fact] + public void Apply_Takes_ANewDutyCycle_AndClampsItTheSameWay() + { + DutyCycle cycle = new(30, 10); + + cycle.Apply(true, 100000, 0); + + Assert.Equal(300, cycle.BurstTicks); + Assert.Equal(1, cycle.IntervalSeconds); + } +} diff --git a/Pulse.Tests/TickAttributionTests.cs b/Pulse.Tests/TickAttributionTests.cs index 7707699..41baaa1 100644 --- a/Pulse.Tests/TickAttributionTests.cs +++ b/Pulse.Tests/TickAttributionTests.cs @@ -6,6 +6,9 @@ namespace Pulse.Tests; +/// Attribution's own arithmetic, and what it does with each step of its duty cycle: which +/// tick's tree it folds, when it publishes, what it drops when the cycle is restarted. The schedule +/// alone is DutyCycleTests's. public class TickAttributionTests { /// One profiled tick as the engine leaves it: a thousand ticks of wall time, four @@ -60,19 +63,8 @@ private static AttributionBurst Cycle(TickAttribution attribution, ProfileEntryR private static double Share(AttributionBurst burst, string modid) => burst.Seconds.Single(entry => entry.Key == modid).Value / burst.BusySeconds; - [Fact] - public void Constructor_Floors_TheIntervalAndTheBurstLength() - { - TickAttribution attribution = new(0, 0); - - Assert.Equal(1, attribution.BurstTicks); - Assert.Equal(TickAttribution.MinimumIntervalSeconds, attribution.IntervalSeconds); - } - - [Fact] - public void Constructor_Caps_TheBurstLength() - => Assert.Equal(TickAttribution.MaximumBurstTicks, new TickAttribution(100000, 10).BurstTicks); - + /// The cycle's own clamping is DutyCycleTests's. What this checks is that the two + /// numbers reach the cycle the right way round, and come back out of it. [Fact] public void Constructor_Keeps_AConfiguredDutyCycle() { @@ -82,21 +74,6 @@ public void Constructor_Keeps_AConfiguredDutyCycle() Assert.Equal(10, attribution.IntervalSeconds); } - [Fact] - public void OnTick_LeavesTheProfilerOff_UntilTheIntervalHasPassed() - { - TickAttribution attribution = new(5, 10); - - for (int tick = 0; tick < 9; tick++) - { - Assert.Null(attribution.OnTick(1.0, Tick(), Owners)); - Assert.False(attribution.Profiling); - } - - Assert.Null(attribution.OnTick(1.0, Tick(), Owners)); - Assert.True(attribution.Profiling); - } - /// The tick that turns the profiler on never got its Begin(), so the tree it ends with /// is whatever the last burst left behind. Folding it would count that stale tick again. [Fact] @@ -273,6 +250,29 @@ public void Apply_Drops_ABurstInProgress_WhenItIsSwitchedOff() Assert.False(attribution.Profiling); } + /// What a burst had folded when it was cut short, by a reload or by switching attribution + /// off and on, is thrown away with it: the next burst publishes its own samples and nothing of the + /// ones that were dropped. + [Fact] + public void Apply_Clears_WhatTheBurstInProgressHadFolded() + { + TickAttribution attribution = new(3, 1); + + // The start, the warm-up and two of the three samples: cut short just before the last. + for (int tick = 0; tick < 4; tick++) + { + Assert.Null(attribution.OnTick(1.0, Tick(), Owners)); + } + + attribution.Apply(true, 3, 1); + AttributionBurst burst = Cycle(attribution, Tick(), Owners); + AttributionBurst fresh = Cycle(new TickAttribution(3, 1), Tick(), Owners); + + Assert.Equal(fresh.Ticks, burst.Ticks); + Assert.Equal(fresh.BusySeconds, burst.BusySeconds, 12); + Assert.Equal(fresh.Seconds, burst.Seconds); + } + /// Re-enabling starts a fresh cycle, so the stale tree the profiler left behind while /// it was off is discarded rather than folded into the first burst. [Fact] @@ -290,17 +290,6 @@ public void Apply_Discards_TheStaleSample_WhenItIsSwitchedBackOn() Assert.Equal(1, burst.Ticks); } - [Fact] - public void Apply_Takes_ANewDutyCycle_AndClampsItTheSameWay() - { - TickAttribution attribution = new(30, 10); - - attribution.Apply(true, 100000, 0); - - Assert.Equal(TickAttribution.MaximumBurstTicks, attribution.BurstTicks); - Assert.Equal(TickAttribution.MinimumIntervalSeconds, attribution.IntervalSeconds); - } - /// What /pulse attribution status reports, and it has to match the tick counter /// on the wire: both count the ticks completed bursts folded. [Fact] diff --git a/Pulse/DutyCycle.cs b/Pulse/DutyCycle.cs new file mode 100644 index 0000000..db94502 --- /dev/null +++ b/Pulse/DutyCycle.cs @@ -0,0 +1,126 @@ +namespace Pulse; + +/// The schedule of a measurement too costly to leave running: idle for an interval, then +/// a burst of consecutive ticks, then idle again. +/// Only the schedule. It is told how long each tick took and says what this tick is for +/// (); what a burst switches on, what it reads and where it publishes is the +/// caller's, and nothing here knows about meters, the server or the engine. That is what makes the +/// whole schedule drivable from a unit test, and what lets every measurement that runs in bursts +/// share it instead of writing it out again. +/// A burst starts on one tick, spends the next as a warm-up, and takes +/// samples after that. The warm-up exists because whatever the caller +/// switches on at the start is switched on part-way through that tick, and what the caller reads at +/// the start of the next one describes the tick before: a tick only partly covered, so it never +/// counts. Every sample counts toward the burst whatever the caller managed to read on it, so a +/// burst always ends. +internal sealed class DutyCycle +{ + /// Shortest interval between bursts. The duty cycle is the whole reason a measurement + /// like this is affordable, so it stays a duty cycle. + public const int MinimumIntervalSeconds = 1; + + /// Longest burst. Ten seconds at the default tick rate, which is already far more than + /// tick composition varies over. + public const int MaximumBurstTicks = 300; + + private double idleSeconds; + private int burstTicksElapsed; + private bool warm; + + public DutyCycle(int burstTicks, int intervalSeconds, bool enabled = true) + => Apply(enabled, burstTicks, intervalSeconds); + + /// Whether the cycle runs at all. Off, every tick is . + public bool Enabled { get; private set; } + + /// Samples per burst, not counting the warm-up. + public int BurstTicks { get; private set; } + + /// Seconds between the end of one burst and the start of the next. + public int IntervalSeconds { get; private set; } + + /// Whether a burst is running as of the last tick: true after a start, a warm-up and a + /// sample, false after an idle tick and after the last sample, which ends the burst. + public bool InBurst { get; private set; } + + /// Takes a cycle, clamped the way the config file's is, and starts it over. + /// Restarting rather than adjusting in place is what makes switching this off + /// mid-burst safe: the burst in progress is dropped instead of finished, and a later switch-on + /// begins from idle with the whole interval still to wait. A new interval or burst length + /// applied to a running cycle restarts it the same way. + public void Apply(bool enabled, int burstTicks, int intervalSeconds) + { + Enabled = enabled; + BurstTicks = Math.Clamp(burstTicks, 1, MaximumBurstTicks); + IntervalSeconds = Math.Max(MinimumIntervalSeconds, intervalSeconds); + Restart(); + } + + /// Advances the schedule by one tick of and says + /// what the caller does with it. + public DutyStep OnTick(double elapsedSeconds) + { + if (!Enabled) + { + return DutyStep.Idle; + } + + if (!InBurst) + { + idleSeconds += elapsedSeconds; + if (idleSeconds < IntervalSeconds) + { + return DutyStep.Idle; + } + + InBurst = true; + return DutyStep.Start; + } + + if (!warm) + { + warm = true; + return DutyStep.WarmUp; + } + + if (++burstTicksElapsed < BurstTicks) + { + return DutyStep.Sample; + } + + Restart(); + return DutyStep.LastSample; + } + + /// Back to idle, with the interval counted from now. + private void Restart() + { + InBurst = false; + idleSeconds = 0; + burstTicksElapsed = 0; + warm = false; + } +} + +/// What one tick of a asks of whoever owns the work. +internal enum DutyStep +{ + /// Nothing: the cycle is off, or still waiting out its interval. + Idle, + + /// The interval has passed and a burst starts on this tick: whatever the burst needs + /// switched on gets switched on now. + Start, + + /// The tick after the start. What the caller reads on it describes the tick the burst + /// started in, which was only partly covered, so it is not a sample. + WarmUp, + + /// A tick of the burst to read a sample on. + Sample, + + /// The burst's last sample, read the same way as any other. The burst is over by the + /// time this is returned: the cycle is idle again and is + /// false. + LastSample, +} diff --git a/Pulse/TickAttribution.cs b/Pulse/TickAttribution.cs index 4c686c9..88b10d4 100644 --- a/Pulse/TickAttribution.cs +++ b/Pulse/TickAttribution.cs @@ -8,12 +8,12 @@ namespace Pulse; -/// The duty cycle and the arithmetic behind per-mod tick attribution: when the engine's -/// frame profiler should be running, and how one profiled tick's mark tree becomes seconds per -/// mod. +/// The arithmetic behind per-mod tick attribution, on the schedule of a +/// : when the engine's frame profiler should be running, and how one +/// profiled tick's mark tree becomes seconds per mod. /// Knows nothing about meters, the server or the profiler flag itself. It is handed the /// previous tick's completed tree and says whether the profiler should be on when the current tick -/// ends, which is what makes the whole duty cycle drivable from a unit test. +/// ends. internal sealed class TickAttribution { /// Everything the engine spends on itself: its own server systems, the time between @@ -30,14 +30,6 @@ internal sealed class TickAttribution /// PropertyName(). public const string BehaviorPrefix = "done-behavior-"; - /// Shortest interval between bursts. The duty cycle is the whole reason this is - /// affordable, so it stays a duty cycle. - public const int MinimumIntervalSeconds = 1; - - /// Longest burst. Ten seconds of profiling at the default tick rate, which is already - /// far more than tick composition varies over. - public const int MaximumBurstTicks = 300; - /// The engine's bucket for the throttle sleep, charged in ServerMain.Process /// (1.22.7:1553). It is the one root mark that is not work, so it is what busy time is measured /// against rather than attributed. @@ -50,31 +42,31 @@ internal sealed class TickAttribution /// other mark in the tree is the engine's own. private static readonly string[] OwnedPrefixes = ["gmle", "gmlb", "dce", "dcb", "sdcb", BehaviorPrefix]; + private readonly DutyCycle cycle; + private readonly Dictionary ticksByMod = []; /// Every mod that has appeared in any burst so far, so one that goes quiet publishes a /// zero instead of freezing its gauge at the share it had when it stopped. private readonly HashSet seenMods = []; - private double idleSeconds; - private int burstTicksElapsed; private int sampled; private long busyTicks; private long dropped; - private bool warm; public TickAttribution(int burstTicks, int intervalSeconds, bool enabled = true) - => Apply(enabled, burstTicks, intervalSeconds); + => cycle = new DutyCycle(burstTicks, intervalSeconds, enabled); /// Whether the duty cycle runs at all. - public bool Enabled { get; private set; } + public bool Enabled => cycle.Enabled; - public int BurstTicks { get; private set; } + public int BurstTicks => cycle.BurstTicks; - public int IntervalSeconds { get; private set; } + public int IntervalSeconds => cycle.IntervalSeconds; - /// Whether the engine's frame profiler has to be enabled when the current tick ends. - public bool Profiling { get; private set; } + /// Whether the engine's frame profiler has to be enabled when the current tick ends: + /// exactly while the duty cycle is inside a burst. + public bool Profiling => cycle.InBurst; /// Ticks folded into a completed burst since the server booted, which is the number /// pulse_attribution_ticks_total reports. @@ -86,10 +78,8 @@ public TickAttribution(int burstTicks, int intervalSeconds, bool enabled = true) /// back off on the next tick, and a later switch-on begins from a clean burst. public void Apply(bool enabled, int burstTicks, int intervalSeconds) { - Enabled = enabled; - BurstTicks = Math.Clamp(burstTicks, 1, MaximumBurstTicks); - IntervalSeconds = Math.Max(MinimumIntervalSeconds, intervalSeconds); - Restart(); + cycle.Apply(enabled, burstTicks, intervalSeconds); + ClearBurst(); } /// Advances the duty cycle by one tick, folding when @@ -97,48 +87,25 @@ public void Apply(bool enabled, int burstTicks, int intervalSeconds) /// one. public AttributionBurst? OnTick(double elapsedSeconds, ProfileEntryRange? previousTick, OwnerLookup owner) { - if (!Enabled) - { - return null; - } + DutyStep step = cycle.OnTick(elapsedSeconds); - if (!Profiling) + // Idle, the start and the warm-up carry nothing to fold. On the warm-up, the previous tick + // is the one the profiler was switched on part-way through: it never got its Begin(), and + // the tree it ended with is whatever the last burst left in the profiler. One stale sample + // per burst, discarded here rather than folded. + if (step is not (DutyStep.Sample or DutyStep.LastSample)) { - idleSeconds += elapsedSeconds; - if (idleSeconds < IntervalSeconds) - { - return null; - } - - idleSeconds = 0; - burstTicksElapsed = 0; - warm = false; - Profiling = true; - return null; - } - - // The profiler was switched on part-way through the previous tick, so that tick never got - // its Begin() and the tree it ended with is whatever the last burst left in the profiler. - // One stale sample per burst, discarded here rather than folded. - if (!warm) - { - warm = true; return null; } + // The cycle counted this sample whether or not there was a tree to read, so a burst always + // ends and the profiler always goes back off. if (previousTick != null) { Fold(previousTick, owner); } - // Counted whether or not there was a tree to read, so a burst always ends and the profiler - // always goes back off. - if (++burstTicksElapsed < BurstTicks) - { - return null; - } - - return Take(); + return step == DutyStep.LastSample ? Take() : null; } /// Folds one completed tick's tree into the burst. @@ -228,7 +195,8 @@ private void Add(string modid, long ticks) ticksByMod[modid] = accumulated + ticks; } - /// Closes the burst and starts the next one empty. + /// Closes the burst and starts the next one empty. The duty cycle has already gone + /// back to idle by the time this runs, so the profiler is off from the next tick. private AttributionBurst Take() { double frequency = Stopwatch.Frequency; @@ -241,20 +209,16 @@ private AttributionBurst Take() AttributionBurst burst = new(seconds, busyTicks / frequency, sampled, dropped); TicksProfiled += sampled; - Restart(); + ClearBurst(); return burst; } - /// Back to idle with nothing accumulated, and the profiler off from the next tick. - /// Everything a burst gathers is dropped here, but seenMods is not: a mod that - /// has been measured once keeps publishing a zero rather than freezing its gauge, whether the - /// burst ended on its own or an operator cut it short. - private void Restart() + /// Nothing accumulated: what a burst gathers so far is dropped. + /// Not seenMods: a mod that has been measured once keeps publishing a zero + /// rather than freezing its gauge, whether the burst ended on its own or an operator cut it + /// short. + private void ClearBurst() { - Profiling = false; - idleSeconds = 0; - burstTicksElapsed = 0; - warm = false; ticksByMod.Clear(); busyTicks = 0; sampled = 0; diff --git a/tools/mutation-check.sh b/tools/mutation-check.sh index 535d829..5a59be1 100755 --- a/tools/mutation-check.sh +++ b/tools/mutation-check.sh @@ -52,7 +52,7 @@ mutate() { #