diff --git a/ClockQuantization.sln b/ClockQuantization.sln new file mode 100644 index 0000000..895c4f3 --- /dev/null +++ b/ClockQuantization.sln @@ -0,0 +1,36 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 16 +VisualStudioVersion = 16.0.30804.86 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ClockQuantization", "src\ClockQuantization.csproj", "{CB1FF5D7-446B-44D0-B9B3-DE204F67FC3C}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{7406849D-980E-42B0-8BCE-FBDAB80362C5}" + ProjectSection(SolutionItems) = preProject + readme.md = readme.md + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ClockQuantization.Tests", "tests\ClockQuantization.Tests\ClockQuantization.Tests.csproj", "{7C538D0C-2B25-481A-92C8-130A20336C6F}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {CB1FF5D7-446B-44D0-B9B3-DE204F67FC3C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CB1FF5D7-446B-44D0-B9B3-DE204F67FC3C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CB1FF5D7-446B-44D0-B9B3-DE204F67FC3C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CB1FF5D7-446B-44D0-B9B3-DE204F67FC3C}.Release|Any CPU.Build.0 = Release|Any CPU + {7C538D0C-2B25-481A-92C8-130A20336C6F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7C538D0C-2B25-481A-92C8-130A20336C6F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7C538D0C-2B25-481A-92C8-130A20336C6F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7C538D0C-2B25-481A-92C8-130A20336C6F}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {4D8B842C-B349-4411-84A3-55C62613E60E} + EndGlobalSection +EndGlobal diff --git a/readme.md b/readme.md new file mode 100644 index 0000000..69ec250 --- /dev/null +++ b/readme.md @@ -0,0 +1,53 @@ +# Introduction + +Clock Quantization is proposed as a method to decrease the frequency of determining the exact time, while still being +able to make time-dependent decisions. + +Consider an interval defined as a time span for which we register a "start" `DateTimeOffset` and keep an internal +serial position. The latter is incremented every time that we issue a new so-called time-serial position. Every time +that such time-serial position is created off said interval, it will assume two properties: +1. A `DateTimeOffset` which is the copy of the interval's `DateTimeOffset` +2. A `SerialPosition` which is a copy of the interval's internal serial position at the time of issuance. + +An internal "metronome" ensures that a new interval is periodically started after `MaxIntervalTimeSpan` has passed. + +The result: +* The continuous clock is quantized into regular intervals with known maximum length, allowing system calls (e.g. `DateTimeOffset.UtcNow`) + to be required less frequent and amortized across multiple operations. +* The status of "events" that occur outside of the interval can be reasoned about with absolute certainty. E.g., taking `interval` as a reference frame: + * A cache item that expires before `interval.DateTimeOffset` will definitely have expired + * A cache item that expires after `interval.DateTimeOffset + MaxIntervalTimeSpan` will definitely expire in the future (i.e. it has + not expired yet) +* The status of "events" that occur inside the interval is in doubt, but policies could define how to deal with that. E.g., + with the same example of cache item expiration, one of the following policies could be applied: + * Precise → fall back to determining a precise `DateTimeOffset`, incurring a call onto the clock and applying the "normal" logic. + * Optimistic → just consider the item not expired yet + * Pessimistic → just consider the item as already expired +* The amount of uncertainty (i.e. in-doubt "events") becomes a function of `MaxIntervalTimeSpan`. The smaller `MaxIntervalTimeSpan`, + the smaller the amount of uncertainty (and number of times that we still need to regress to calling onto the clock to determine + the exact time). +* The time-serial positions within an interval can be ordered by their `SerialPosition` property, still allowing for e.g. + strict LRU orderering. Alternatively, it also makes it possible to apply LRU ordering to a cluster of cache entries (all having + equal `LastAccessed.DateTimeOffset` - now also a time-serial position). + +# Context +This repo stems from additional research after I posted a [comment](https://github.com/dotnet/runtime/pull/45842#issuecomment-742100677) in PR [dotnet/runtime#45842](https://github.com/dotnet/runtime/pull/45842). In that comment, I did not take into +account the fact that `CacheEntry.LastAccessed` is often updated with `DateTimeOffset.UtcNow` in order to support: +* Sliding expiration +* LRU-based cache eviction during cache compaction + +After some local experimentation with `Lazy` (as suchested in my initial comment) and +[`LazyInitializer.EnsureInitialized()`](https://docs.microsoft.com/en-us/dotnet/api/system.threading.lazyinitializer.ensureinitialized?view=net-5.0#System_Threading_LazyInitializer_EnsureInitialized__1___0__System_Boolean__System_Object__System_Func___0__), +I did get some promising results, but realized that it resulted in some quite convoluted code. Hence, I decided to first create and +share an abstraction that reflects the idea behind a potential direction for further optimization. + +# Remarks +* The `ISystemClockTemporalContext` abstraction introduced as part of this concept might be useful in other scenarios where a + synthetic clock and/or timer must be imposed onto a subject/system (e.g. event replay, unit tests). +* The solution in this repo contains a test project. It serves two purposes: + 1. Ensure that I didn't leave too many gaps + 2. Document the idea behind `ISystemClockTemporalContext`, without actually writing documentation + +# Remaining work +- [ ] Implement `IDisposable` and/or `IAsyncDisposable` on `ClockQuantizer` +- [ ] Integrate this proposal in a [local fork](https://github.com/edevoogd/runtime) of Microsoft.Extensions.Caching.Memory as a PoC \ No newline at end of file diff --git a/src/ClockQuantization.csproj b/src/ClockQuantization.csproj new file mode 100644 index 0000000..9faf376 --- /dev/null +++ b/src/ClockQuantization.csproj @@ -0,0 +1,19 @@ + + + + Library + 9.0 + netstandard2.0;net50 + enable + + + + .\ClockQuantization.xml + Off + + + + + + + \ No newline at end of file diff --git a/src/ClockQuantization.xml b/src/ClockQuantization.xml new file mode 100644 index 0000000..1e864fe --- /dev/null +++ b/src/ClockQuantization.xml @@ -0,0 +1,236 @@ + + + + ClockQuantization + + + + + is a utility class that abstracts quantization of the reference clock. Essentially, the reference clock continuum is divided into discrete intervals with a maximum length of . + A so-called metronome is used to start a new every time when has passed. A may be cut short when an "out-of-cadance" advance operation is performed - such operation is triggered by + calls, as well as by and events. + + Under certain conditions, an advance operation may be incurred by calls. + + + + The maximum of each , defined at construction. + + + + The current in the 's temporal context. + A starts in an inhibited state. Only after the first advance operation, will have a non- value. + + + Returns the value of the reference clock. + Depending on the actual reference clock implementation, this may or may not incur an expensive system call. + + + + Establishes a new lower bound on the "last seen" exact within the + 's temporal context: the reference clock's . + + The newly started . + + + + If does not have an exact yet, it will be initialized with one. In every + situation where initialization is still required, this will incur a call into the reference clock's . + + Reference to an (on-stack) which may or may not have been initialized. + Indicates if the should perform an advance operation. This is advised in situations where non-exact + positions may still be acquired in the same and exact ordering (e.g. in a cache LRU eviction algorithm) might be adversely affected. + + An advance operation will incur an event. + Depending on the actual reference clock implementation, this may or may not incur an expensive system call. + + + + + If does not have a yet, it will be initialized with one. + + Reference to an (on-stack) which may or may not have been initialized. + + If the did not perform a first advance operation yet, the result will be an exact position + (incurring a call into the reference clock's ). Otherwise, returns a position bound to + 's , but with an incremented . + + + + + Represents the ephemeral conditions at the time of an advance operation. + + + + + The within the temporal context when the new was started. + + + + + if the new was created due to a metronome "tick", otherwise. + + + + + An optional value representing the gap between the start of the new interval and the expected end of + , if such gap is in fact detected. + + + + + This event is fired direclty after the start of a new within the 's temporal context. + + + + + This event is fired almost immediately after each "tick" of the metronome. Every event is preceeded by an advance operation and a corresponding event, ensuring that a new reference + has been established in the 's temporal context at the time of firing. + + + Under typical operating conditions, the intermittent elapse of every interval is signaled by the 's built-in metronome. + Alternatively, metronome "ticks" may be generated by an external source that is firing events. + + + + + + Raises the event. May be overriden in derived implementations. + + A instance + + + + Raises the event. May be overriden in derived implementations. + + A instance + + events are always preceded with an event. The value of is the same in both consecutive events. + + + + + Creates a new instance. + + The reference + The maximum of each + + If also implements , the will pick up on external + events. Also, if is , + the will pick up on external events, instead of relying on an internal metronome. + + + + + Represents an interval within a 's temporal context. + + + + Within the reference frame of an , there is no notion of time; there is only notion of the order + in which s are issued. + + + Whereas is always progressing with intervals of at most length, + several s may be active concurrently. + + + + + + The within the temporal context when the was started. + + + + + If does not have a yet, it will be initialized with one, + based off 's and its monotonically increasing internal serial position. + + The interval to create the off. + Reference to an (on-stack) which may or may not have been initialized. + + + + Creates a new based off the 's and its monotonically increasing internal serial position. + + A new + + A created at the time when a new is created (e.g. during + ) will have equal + to . + + + + + + Represents a point in time, expressed as a combination of and . Its value may be unitialized, + as indicated by its property. + + + When initialized (i.e. when equals ), the following rules apply: + + Issuance of an "exact" can only occur at start. By definition, will equal + , will equal 1u and will equal . + Any issued off the same with N (N > 1u) was issued + at a later point in (continuous) time than the with equals N-1 and was issued at an earlier + point in (continuous) time than any with > N. + + + + + With several methods available to lazily initialize a by reference, it is possible to create s + on-stack and initialize them as late as possible and only if deemed necessary for the operation/decision at hand. + + + + + + + Returns the assigned to the current value. + When is . + + + Returns the serial position assigned to the current value. + When is . + + + Returns if a value is assigned, otherwise. + + + Returns if a value is assigned and said value represents the first issued at . In other words, + the value was assigned exactly at . + + + + Abstracts the system clock to facilitate synthetic clocks (e.g. for testing). + + + + + The current system time in UTC. + + + + + Represents traits of the temporal context + + + + + if the temporal context provides a metronome feature - i.e., if it fires events. + + + + + An event that can be raised to inform listeners that the was adjusted. + + + This will typically be used with synthetic clocks only. + + + + + An event that can be raised to inform listeners that a metronome "tick" occurred. + + + + diff --git a/src/ClockQuantizer.cs b/src/ClockQuantizer.cs new file mode 100644 index 0000000..ee8eba2 --- /dev/null +++ b/src/ClockQuantizer.cs @@ -0,0 +1,259 @@ +using System; +using System.Runtime.CompilerServices; + +namespace ClockQuantization +{ + /// + /// is a utility class that abstracts quantization of the reference clock. Essentially, the reference clock continuum is divided into discrete intervals with a maximum length of . + /// A so-called metronome is used to start a new every time when has passed. A may be cut short when an "out-of-cadance" advance operation is performed - such operation is triggered by + /// calls, as well as by and events. + /// + /// Under certain conditions, an advance operation may be incurred by calls. + public class ClockQuantizer //: IAsyncDisposable, IDisposable + { + private struct AdvancePreparationInfo + { + public Interval Interval; + public ClockQuantizer.NewIntervalEventArgs EventArgs; + + public AdvancePreparationInfo(Interval interval, ClockQuantizer.NewIntervalEventArgs eventArgs) + { + Interval = interval; + EventArgs = eventArgs; + } + } + + private readonly ISystemClock _clock; + private Interval? _currentInterval; + private readonly System.Threading.Timer? _metronome; + + + // Properties + /// + /// The maximum of each , defined at construction. + /// + public readonly TimeSpan MaxIntervalTimeSpan; + + /// The current in the 's temporal context. + /// A starts in an inhibited state. Only after the first advance operation, will have a non- value. + public Interval? CurrentInterval { get => _currentInterval; } + + /// Returns the value of the reference clock. + /// Depending on the actual reference clock implementation, this may or may not incur an expensive system call. + public DateTimeOffset UtcNow { get => NewDisconnectedInterval().DateTimeOffset; } + + + // Basic quantizer operations + /// + /// Establishes a new lower bound on the "last seen" exact within the + /// 's temporal context: the reference clock's . + /// + /// The newly started . + public Interval Advance() => Advance(metronomic: false); + + + // Basic position operations + /// + /// If does not have an exact yet, it will be initialized with one. In every + /// situation where initialization is still required, this will incur a call into the reference clock's . + /// + /// Reference to an (on-stack) which may or may not have been initialized. + /// Indicates if the should perform an advance operation. This is advised in situations where non-exact + /// positions may still be acquired in the same and exact ordering (e.g. in a cache LRU eviction algorithm) might be adversely affected. + /// + /// An advance operation will incur an event. + /// Depending on the actual reference clock implementation, this may or may not incur an expensive system call. + /// + public void EnsureInitializedExactTimeSerialPosition(ref LazyTimeSerialPosition position, bool advance) + { + if (!position.IsExact) // test here as well to prevent unnecessary/unexpected Advance() if position was already initialzed + { + if (advance) + { + var preparation = PrepareAdvance(metronomic: false); + Interval.EnsureInitializedTimeSerialPosition(preparation.Interval, ref position); + CommitAdvance(preparation); + } + else + { + Interval.EnsureInitializedTimeSerialPosition(NewDisconnectedInterval(), ref position); + } + } + } + + /// + /// If does not have a yet, it will be initialized with one. + /// + /// Reference to an (on-stack) which may or may not have been initialized. + /// + /// If the did not perform a first advance operation yet, the result will be an exact position + /// (incurring a call into the reference clock's ). Otherwise, returns a position bound to + /// 's , but with an incremented . + /// + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public void EnsureInitializedTimeSerialPosition(ref LazyTimeSerialPosition position) + { + if (!position.HasValue) + { + Interval.EnsureInitializedTimeSerialPosition(_currentInterval ?? NewDisconnectedInterval(), ref position); + } + } + + // Events + /// + /// Represents the ephemeral conditions at the time of an advance operation. + /// + public class NewIntervalEventArgs : EventArgs + { + /// + /// The within the temporal context when the new was started. + /// + public readonly DateTimeOffset DateTimeOffset; + + /// + /// if the new was created due to a metronome "tick", otherwise. + /// + public readonly bool IsMetronomic; + + /// + /// An optional value representing the gap between the start of the new interval and the expected end of + /// , if such gap is in fact detected. + /// + public readonly TimeSpan? GapToPriorIntervalExpectedEnd; + + internal NewIntervalEventArgs(DateTimeOffset offset, bool metronomic, TimeSpan? gap) + { + DateTimeOffset = offset; + IsMetronomic = metronomic; + GapToPriorIntervalExpectedEnd = gap; + } + } + + /// + /// This event is fired direclty after the start of a new within the 's temporal context. + /// + public event EventHandler? Advanced; + + /// + /// This event is fired almost immediately after each "tick" of the metronome. Every event is preceeded by an advance operation and a corresponding event, ensuring that a new reference + /// has been established in the 's temporal context at the time of firing. + /// + /// + /// Under typical operating conditions, the intermittent elapse of every interval is signaled by the 's built-in metronome. + /// Alternatively, metronome "ticks" may be generated by an external source that is firing events. + /// + /// + public event EventHandler? MetronomeTicked; + + /// + /// Raises the event. May be overriden in derived implementations. + /// + /// A instance + protected virtual void OnAdvanced(NewIntervalEventArgs e) => Advanced?.Invoke(this, e); + + /// + /// Raises the event. May be overriden in derived implementations. + /// + /// A instance + /// + /// events are always preceded with an event. The value of is the same in both consecutive events. + /// + protected virtual void OnMetronomeTicked(NewIntervalEventArgs e) => MetronomeTicked?.Invoke(this, e); + + + // Construction + + /// + /// Creates a new instance. + /// + /// The reference + /// The maximum of each + /// + /// If also implements , the will pick up on external + /// events. Also, if is , + /// the will pick up on external events, instead of relying on an internal metronome. + /// + public ClockQuantizer(ISystemClock clock, TimeSpan maxIntervalTimeSpan) + { + _clock = clock; + MaxIntervalTimeSpan = maxIntervalTimeSpan; + bool metronomic = true; + + if (clock is ISystemClockTemporalContext context) + { + context.ClockAdjusted += Context_ClockAdjusted; + if (context.ProvidesMetronome) + { + // Allow external "pulse" on metronome ticks + context.MetronomeTicked += Context_MetronomeTicked; + metronomic = false; + } + } + + if (metronomic) + { + // Create a suspended timer. Timer will be started at first call to Advance(). + _metronome = new System.Threading.Timer(Metronome_TimerCallback, null, System.Threading.Timeout.InfiniteTimeSpan, maxIntervalTimeSpan); + } + } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private Interval NewDisconnectedInterval() => new Interval(_clock.UtcNow); + + private Interval Advance(bool metronomic) + { + var preparation = PrepareAdvance(metronomic); + return CommitAdvance(preparation); + } + + private AdvancePreparationInfo PrepareAdvance(bool metronomic) + { + // Start metronome (if not imposed externally) on first Advance and consider first Advance as a metronomic event. + if (_currentInterval is null && _metronome is not null) + { + metronomic = true; + _metronome.Change(MaxIntervalTimeSpan, MaxIntervalTimeSpan); + } + + var previousInterval = _currentInterval; + var interval = NewDisconnectedInterval(); + TimeSpan? detectedGap = null; + if (previousInterval is not null) + { + // Ignore potential *internal* metronome gap due to tiny clock jitter + if (!metronomic || _metronome is null) + { + var gap = interval.DateTimeOffset - (previousInterval.DateTimeOffset + MaxIntervalTimeSpan); + if (gap > TimeSpan.Zero) + { + detectedGap = gap; + } + } + } + + var e = new NewIntervalEventArgs(interval.DateTimeOffset, metronomic, detectedGap); + + return new AdvancePreparationInfo(interval, e); + } + + private Interval CommitAdvance(AdvancePreparationInfo preparation) + { + _currentInterval = preparation.Interval.Seal(); ; + var e = preparation.EventArgs; + OnAdvanced(e); + + if (e.IsMetronomic) + { + OnMetronomeTicked(e); + } + + return preparation.Interval; + } + + private void Metronome_TimerCallback(object? _) => Context_MetronomeTicked(null, EventArgs.Empty); + + private void Context_MetronomeTicked(object? _, EventArgs __) => Advance(metronomic: true); + + private void Context_ClockAdjusted(object? _, EventArgs __) => Advance(metronomic: false); + } +} \ No newline at end of file diff --git a/src/Interval.cs b/src/Interval.cs new file mode 100644 index 0000000..6f6b680 --- /dev/null +++ b/src/Interval.cs @@ -0,0 +1,91 @@ +using System; +using System.Runtime.CompilerServices; +using System.Threading; + +namespace ClockQuantization +{ + /// + /// Represents an interval within a 's temporal context. + /// + /// + /// + /// Within the reference frame of an , there is no notion of time; there is only notion of the order + /// in which s are issued. + /// + /// + /// Whereas is always progressing with intervals of at most length, + /// several s may be active concurrently. + /// + /// + public class Interval + { + internal struct SnapshotGenerator + { + internal uint SerialPosition; + internal readonly DateTimeOffset DateTimeOffset; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static ref readonly SnapshotGenerator WithNextSerialPosition(ref SnapshotGenerator generator) + { +#if NET5_0 + Interlocked.Increment(ref generator.SerialPosition); +#else + Interlocked.Add(ref Unsafe.As(ref generator.SerialPosition), 1); +#endif + return ref generator; + } + + internal SnapshotGenerator(in DateTimeOffset offset) { SerialPosition = 0u; DateTimeOffset = offset; } + } + + private SnapshotGenerator _generator; + + /// + /// The within the temporal context when the was started. + /// + public DateTimeOffset DateTimeOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _generator.DateTimeOffset; } + + internal Interval(in DateTimeOffset offset) => _generator = new SnapshotGenerator(in offset); + + + /// + /// If does not have a yet, it will be initialized with one, + /// based off 's and its monotonically increasing internal serial position. + /// + /// The interval to create the off. + /// Reference to an (on-stack) which may or may not have been initialized. + [MethodImpl(MethodImplOptions.AggressiveInlining)] + public static void EnsureInitializedTimeSerialPosition(Interval interval, ref LazyTimeSerialPosition position) + { + if (position.HasValue && interval._generator.SerialPosition > 0u) + { + return; + } + + LazyTimeSerialPosition.ApplySnapshot(ref position, in SnapshotGenerator.WithNextSerialPosition(ref interval._generator)); + } + + /// + /// Creates a new based off the 's and its monotonically increasing internal serial position. + /// + /// A new + /// + /// A created at the time when a new is created (e.g. during + /// ) will have equal + /// to . + /// + public LazyTimeSerialPosition NewTimeSerialPosition() => new LazyTimeSerialPosition(in SnapshotGenerator.WithNextSerialPosition(ref _generator)); + + internal Interval Seal() + { + // Prevent 'Exact' positions post initialization of the Interval; ensure SerialPosition > 0 +#if NET5_0 + Interlocked.CompareExchange(ref _generator.SerialPosition, 1u, 0u); +#else + Interlocked.CompareExchange(ref Unsafe.As(ref _generator.SerialPosition), 1, 0); +#endif + + return this; + } + } +} \ No newline at end of file diff --git a/src/LazyTimeSerialPosition.cs b/src/LazyTimeSerialPosition.cs new file mode 100644 index 0000000..33a5333 --- /dev/null +++ b/src/LazyTimeSerialPosition.cs @@ -0,0 +1,79 @@ +using System; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace ClockQuantization +{ + /// + /// + /// Represents a point in time, expressed as a combination of and . Its value may be unitialized, + /// as indicated by its property. + /// + /// + /// When initialized (i.e. when equals ), the following rules apply: + /// + /// Issuance of an "exact" can only occur at start. By definition, will equal + /// , will equal 1u and will equal . + /// Any issued off the same with N (N > 1u) was issued + /// at a later point in (continuous) time than the with equals N-1 and was issued at an earlier + /// point in (continuous) time than any with > N. + /// + /// + /// + /// + /// With several methods available to lazily initialize a by reference, it is possible to create s + /// on-stack and initialize them as late as possible and only if deemed necessary for the operation/decision at hand. + /// + /// + /// + /// + public struct LazyTimeSerialPosition + + { + private static class ThrowHelper + { + [MethodImpl(MethodImplOptions.NoInlining)] + public static InvalidOperationException CreateInvalidOperationException() => new InvalidOperationException(); + } + + private readonly struct Snapshot + { + public readonly DateTimeOffset DateTimeOffset; + public readonly uint SerialPosition; + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal Snapshot(in Interval.SnapshotGenerator generator) + { + SerialPosition = generator.SerialPosition; + DateTimeOffset = generator.DateTimeOffset; + } + } + + private Snapshot _snapshot; + + /// Returns the assigned to the current value. + /// When is . + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public readonly DateTimeOffset DateTimeOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => HasValue ? _snapshot.DateTimeOffset : throw ThrowHelper.CreateInvalidOperationException(); } + + /// Returns the serial position assigned to the current value. + /// When is . + [DebuggerBrowsable(DebuggerBrowsableState.Never)] + public readonly uint SerialPosition { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => HasValue ? _snapshot.SerialPosition : throw ThrowHelper.CreateInvalidOperationException(); } + + /// Returns if a value is assigned, otherwise. + public readonly bool HasValue { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _snapshot.SerialPosition > 0u; } + + /// Returns if a value is assigned and said value represents the first issued at . In other words, + /// the value was assigned exactly at . + public readonly bool IsExact { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _snapshot.SerialPosition == 1u; } + + internal LazyTimeSerialPosition(in Interval.SnapshotGenerator generator) { _snapshot = new Snapshot(in generator); } + + [MethodImpl(MethodImplOptions.AggressiveInlining)] + internal static void ApplySnapshot(ref LazyTimeSerialPosition position, in Interval.SnapshotGenerator generator) + { + position._snapshot = new Snapshot(in generator); + } + } +} diff --git a/src/TemporalContext.cs b/src/TemporalContext.cs new file mode 100644 index 0000000..fd8ff38 --- /dev/null +++ b/src/TemporalContext.cs @@ -0,0 +1,39 @@ +using System; + +namespace ClockQuantization +{ + /// + /// Abstracts the system clock to facilitate synthetic clocks (e.g. for replay or testing). + /// + public interface ISystemClock + { + /// + /// The current system time in UTC. + /// + DateTimeOffset UtcNow { get; } + } + + /// + /// Represents traits of the temporal context + /// + public interface ISystemClockTemporalContext + { + /// + /// if the temporal context provides a metronome feature - i.e., if it fires events. + /// + bool ProvidesMetronome { get; } + + /// + /// An event that can be raised to inform listeners that the was adjusted. + /// + /// + /// This will typically be used with synthetic clocks only. + /// + event EventHandler? ClockAdjusted; + + /// + /// An event that can be raised to inform listeners that a metronome "tick" occurred. + /// + event EventHandler? MetronomeTicked; + } +} diff --git a/tests/ClockQuantization.Tests/ClockQuantization.Tests.csproj b/tests/ClockQuantization.Tests/ClockQuantization.Tests.csproj new file mode 100644 index 0000000..ccd08e4 --- /dev/null +++ b/tests/ClockQuantization.Tests/ClockQuantization.Tests.csproj @@ -0,0 +1,28 @@ + + + + 9.0 + + netcoreapp3.1;net5.0 + enable + false + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/tests/ClockQuantization.Tests/ClockQuantizerTests.cs b/tests/ClockQuantization.Tests/ClockQuantizerTests.cs new file mode 100644 index 0000000..b44896c --- /dev/null +++ b/tests/ClockQuantization.Tests/ClockQuantizerTests.cs @@ -0,0 +1,758 @@ +using ClockQuantization.Tests.Assets; +using System; +using System.Collections.Generic; +using Xunit; + +namespace ClockQuantization.Tests +{ + public class ClockQuantizerTests + { + [Fact] + public void ClockQuantizer_WithTestClock_AddWithGap_RaisesAdvanceEventWithDetectedGapTimeSpan() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var now = DateTimeOffset.UtcNow; + var offset = TimeSpan.FromMinutes(1); + var context = new SystemClockTemporalContext(now, metronomeOptions); + var advanceEventRaised = false; + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + + quantizer.Advance(); // Advance once to ensure that we have a CurrentInterval + Assert.NotNull(quantizer.CurrentInterval); + Assert.Equal(now, quantizer.CurrentInterval!.DateTimeOffset); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + context.Add(offset); + + // Test + Assert.True(advanceEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaised = true; + + // Test + Assert.Equal(now + offset, e.DateTimeOffset); + Assert.False(e.IsMetronomic); + + Assert.True(e.GapToPriorIntervalExpectedEnd.HasValue); + Assert.Equal(offset - metronomeOptions.MaxIntervalTimeSpan, e.GapToPriorIntervalExpectedEnd!); + } + } + + [Fact] + public void ClockQuantizer_WithTestClock_AddWithoutGap_RaisesAdvanceEventWithoutDetectedGapTimeSpan() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = true, + MaxIntervalTimeSpan = TimeSpan.FromMinutes(5), + }; + var now = DateTimeOffset.UtcNow; + var offset = TimeSpan.FromMinutes(5); + var context = new SystemClockTemporalContext(now, metronomeOptions); + var advanceEventRaised = false; + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + + quantizer.Advance(); // Advance once to ensure that we have a CurrentInterval + Assert.NotNull(quantizer.CurrentInterval); + Assert.Equal(now, quantizer.CurrentInterval!.DateTimeOffset); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + context.Add(offset); + + // Test + Assert.True(advanceEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaised = true; + + // Test + Assert.Equal(now + offset, e.DateTimeOffset); + Assert.False(e.IsMetronomic); + + Assert.False(e.GapToPriorIntervalExpectedEnd.HasValue); + } + } + + [Fact] + public void ClockQuantizer_WithTestClock_AdjustClockWithGap_RaisesAdvanceEventWithDetectedGapTimeSpan() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var now = DateTimeOffset.UtcNow; + var adjusted = now + TimeSpan.FromMinutes(1); + var context = new SystemClockTemporalContext(now, metronomeOptions); + var advanceEventRaised = false; + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + + quantizer.Advance(); // Advance once to ensure that we have a CurrentInterval + Assert.NotNull(quantizer.CurrentInterval); + Assert.Equal(now, quantizer.CurrentInterval!.DateTimeOffset); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + context.AdjustClock(adjusted); + + // Test + Assert.True(advanceEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaised = true; + + // Test + Assert.Equal(adjusted, e.DateTimeOffset); + Assert.False(e.IsMetronomic); + + Assert.True(e.GapToPriorIntervalExpectedEnd.HasValue); + Assert.Equal(adjusted - now - metronomeOptions.MaxIntervalTimeSpan, e.GapToPriorIntervalExpectedEnd!); + } + } + + [Fact] + public void ClockQuantizer_WithTestClock_AdjustClockWithoutGap_RaisesAdvanceEventWithoutDetectedGapTimeSpan() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = true, + MaxIntervalTimeSpan = TimeSpan.FromMinutes(5), + }; + var now = DateTimeOffset.UtcNow; + var adjusted = now + TimeSpan.FromMinutes(5); + var context = new SystemClockTemporalContext(now, metronomeOptions); + var advanceEventRaised = false; + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + + quantizer.Advance(); // Advance once to ensure that we have a CurrentInterval + Assert.NotNull(quantizer.CurrentInterval); + Assert.Equal(now, quantizer.CurrentInterval!.DateTimeOffset); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + context.AdjustClock(adjusted); + + // Test + Assert.True(advanceEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaised = true; + + // Test + Assert.Equal(adjusted, e.DateTimeOffset); + Assert.False(e.IsMetronomic); + + Assert.False(e.GapToPriorIntervalExpectedEnd.HasValue); + } + } + + [Fact] + public void ClockQuantizer_WithExternalManualMetronome_FireMetronomeTicked_RaisesEventsInOrderWithExpectedDateTimeOffset() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + quantizer.MetronomeTicked += Quantizer_MetronomeTicked; + quantizer.Advanced += Quantizer_Advanced; + + DateTimeOffset advanceEventRaisedAt = default; + DateTimeOffset metronomeEventRaisedAt = default; + DateTimeOffset firedAt = DateTimeOffset.UtcNow; + context.FireMetronomeTicked(); + + // Assert that expected events were raised ... + Assert.True(advanceEventRaisedAt != default); + Assert.True(metronomeEventRaisedAt != default); + + // ... in the correct order ... + Assert.True(advanceEventRaisedAt < metronomeEventRaisedAt); + + // ... and after the metronome tick was fired + Assert.True(advanceEventRaisedAt > firedAt); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaisedAt = DateTimeOffset.UtcNow; + + Assert.Equal(e.DateTimeOffset, now); + Assert.True(e.IsMetronomic); + } + + void Quantizer_MetronomeTicked(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + metronomeEventRaisedAt = DateTimeOffset.UtcNow; + + Assert.Equal(e.DateTimeOffset, now); + Assert.True(e.IsMetronomic); + } + } + + [Fact] + public void ClockQuantizer_WithExternalManualMetronome_FireMetronomeTickedWithFutureDateTimeOffset_RaisesEventsInOrderWithExpectedDateTimeOffset() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = DateTimeOffset.UtcNow; // note, not the clock's UtcNow! + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + quantizer.Advanced += Quantizer_Advanced; + quantizer.MetronomeTicked += Quantizer_MetronomeTicked; + + DateTimeOffset advanceEventRaisedAt = default; + DateTimeOffset metronomeEventRaisedAt = default; + DateTimeOffset firedAt = DateTimeOffset.UtcNow; + context.FireMetronomeTicked(now); + + // Assert that expected events were raised ... + Assert.True(advanceEventRaisedAt != default); + Assert.True(metronomeEventRaisedAt != default); + + // ... in the correct order ... + Assert.True(advanceEventRaisedAt < metronomeEventRaisedAt); + + // ... and after the metronome tick was fired + Assert.True(advanceEventRaisedAt > firedAt); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaisedAt = DateTimeOffset.UtcNow; + + Assert.Equal(e.DateTimeOffset, now); + Assert.True(e.IsMetronomic); + } + + void Quantizer_MetronomeTicked(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + metronomeEventRaisedAt = DateTimeOffset.UtcNow; + + Assert.Equal(e.DateTimeOffset, now); + Assert.True(e.IsMetronomic); + } + } + + [Fact] + public void ClockQuantizer_WithExternalManualMetronome_FireMetronomeTickedWithFutureDateTimeOffsetAndGap_RaisesEventsInOrderWithExpectedDateTimeOffsetAndDetectedGap() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = DateTimeOffset.UtcNow; // note, not the clock's UtcNow! + var gap = now - (context.UtcNow + metronomeOptions.MaxIntervalTimeSpan); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + Assert.True(gap > TimeSpan.Zero); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + quantizer.Advance(); + Assert.NotNull(quantizer.CurrentInterval); + + quantizer.Advanced += Quantizer_Advanced; + quantizer.MetronomeTicked += Quantizer_MetronomeTicked; + + DateTimeOffset advanceEventRaisedAt = default; + DateTimeOffset metronomeEventRaisedAt = default; + DateTimeOffset firedAt = DateTimeOffset.UtcNow; + context.FireMetronomeTicked(now); + + // Assert that expected events were raised ... + Assert.True(advanceEventRaisedAt != default); + Assert.True(metronomeEventRaisedAt != default); + + // ... in the correct order ... + Assert.True(advanceEventRaisedAt < metronomeEventRaisedAt); + + // ... and after the metronome tick was fired + Assert.True(advanceEventRaisedAt > firedAt); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + advanceEventRaisedAt = DateTimeOffset.UtcNow; + + Assert.Equal(e.DateTimeOffset, now); + Assert.True(e.IsMetronomic); + + Assert.True(e.GapToPriorIntervalExpectedEnd.HasValue); + Assert.Equal(gap, e.GapToPriorIntervalExpectedEnd); + } + + void Quantizer_MetronomeTicked(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + metronomeEventRaisedAt = DateTimeOffset.UtcNow; + + Assert.Equal(e.DateTimeOffset, now); + Assert.True(e.IsMetronomic); + + Assert.True(e.GapToPriorIntervalExpectedEnd.HasValue); + Assert.Equal(gap, e.GapToPriorIntervalExpectedEnd); + } + } + + [Fact] + public void ClockQuantizer_WithInternalMetronome_RaisesPeriodicMetronomeTickedEventsWithMetronomeJitterGapsIgnored() + { + // Juggling interval parameters to ensure we have an as little flaky as possible P95 test within approx. 1 second + const int intervalCount = 25; + var maxIntervalTimeSpan = TimeSpan.FromMilliseconds(42.5); + var tolerance = TimeSpan.FromMilliseconds(15); // this is just about Timer precision? + + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions: null); + var quantizer = new ClockQuantizer(context, maxIntervalTimeSpan); + var considered = new System.Threading.Semaphore(intervalCount, intervalCount); + var visited = new System.Threading.Semaphore(0, intervalCount); + + var list = new List(intervalCount); + quantizer.MetronomeTicked += Quantizer_MetronomeTicked; + + // Kick off! + var jitters = 0; + var outliers = 0; + var start = quantizer.Advance().DateTimeOffset; + + for (int i = 0; i < intervalCount; i++) + { + visited.WaitOne(maxIntervalTimeSpan + TimeSpan.FromMilliseconds(250)); + } + + Assert.Equal(intervalCount, list.Count); + + for (int i = 0; i < intervalCount; i++) + { + var offset = list[i] - start; + if (!(offset >= i * maxIntervalTimeSpan - tolerance && offset <= i * maxIntervalTimeSpan + tolerance)) + { + outliers++; + } + if (offset > i * maxIntervalTimeSpan) + { + jitters++; + } + } + + // Ensure that we observed at least 1 jitter and validated that the gap was not registered in the event + Assert.True(jitters > 0); + + // Ensure 95% of measurements within tolerance + var p = (double)outliers / (double)intervalCount; + Assert.True(p <= 0.05, $"P95 not achieved; actual: {1.0 - p}"); + + void Quantizer_MetronomeTicked(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + if (considered.WaitOne(0)) + { + list.Add(e.DateTimeOffset); + + // Even jitter should not be registered on *internal* metronome + Assert.False(e.GapToPriorIntervalExpectedEnd.HasValue); + + visited.Release(); + } + } + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithDefaultRef_ByDefinitionMustInitializeExactTimeSerialPosition() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + // Execute + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: false); + + // Test + Assert.True(position.HasValue); + Assert.True(position.IsExact); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithoutAdvanceAndWithExactRef_ByDefinitionRemainsUntouched() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: false); + + Assert.True(position.HasValue); + Assert.True(position.IsExact); + + var copy = position; + + // Execute + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: false); + + // Test + Assert.Equal(copy, position); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithAdvanceAndWithExactRef_ByDefinitionRemainsUntouchedAndDoesNotAdvanceNorRaiseAdvancedEvent() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + var visited = new System.Threading.ManualResetEvent(false); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: false); + + Assert.True(position.HasValue); + Assert.True(position.IsExact); + + var copy = position; + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: true); + Assert.Same(interval, quantizer.CurrentInterval); + + // Test + Assert.Equal(copy, position); + Assert.False(visited.WaitOne(TimeSpan.FromMilliseconds(250))); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + // We should not get here... + visited.Set(); + } + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithNonExactRef_ByDefinitionMustReInitializeExactTimeSerialPosition() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedTimeSerialPosition(ref position); + + Assert.True(position.HasValue); + Assert.False(position.IsExact); + + var copy = position; + + // Execute + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: false); + + // Test + Assert.NotEqual(copy, position); + Assert.True(position.IsExact); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedTimeSerialPosition_AfterFirstAdvanceWithDefaultRef_ByDefinitionMustInitializeNonExactTimeSerialPosition() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + quantizer.Advance(); + + // Execute + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedTimeSerialPosition(ref position); + + // Test + Assert.True(position.HasValue); + Assert.False(position.IsExact); + Assert.Equal(quantizer.CurrentInterval!.DateTimeOffset, position.DateTimeOffset); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedTimeSerialPosition_BeforeFirstAdvanceWithDefaultRef_ByDefinitionMustInitializeExactTimeSerialPosition() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + + // Execute + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedTimeSerialPosition(ref position); + + // Test + Assert.True(position.HasValue); + Assert.True(position.IsExact); + Assert.Null(quantizer.CurrentInterval); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedTimeSerialPosition_WithExactRef_ByDefinitionRemainsUntouched() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: false); + + Assert.True(position.HasValue); + Assert.True(position.IsExact); + + var copy = position; + + // Execute + quantizer.EnsureInitializedTimeSerialPosition(ref position); + + // Test + Assert.Equal(copy, position); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedTimeSerialPosition_WithNonExactRef_ByDefinitionRemainsUntouched() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedTimeSerialPosition(ref position); + + Assert.True(position.HasValue); + Assert.False(position.IsExact); + + var copy = position; + + // Execute + quantizer.EnsureInitializedTimeSerialPosition(ref position); + + // Test + Assert.Equal(copy, position); + } + + [Fact] + public void ClockQuantizer_Advance_YieldsNewSealedIntervalAndRaisesEvent() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var isAdvancedEventRaised = false; + + // Pre-requisites check + Assert.True(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval1 = quantizer.Advance(); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + var interval2 = quantizer.Advance(); + + // Test + Assert.NotSame(interval1, interval2); + Assert.True(interval1.DateTimeOffset < interval2.DateTimeOffset); + + // Ensure interval2 was sealed (and hence a new position can by definition not be "exact") + var position = default(LazyTimeSerialPosition); + Interval.EnsureInitializedTimeSerialPosition(interval2, ref position); + Assert.False(position.IsExact); + + Assert.True(isAdvancedEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + isAdvancedEventRaised = true; + } + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithoutAdvanceAndWithDefaultRef_DoesNotAdvanceCurrentInterval() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + + // Pre-requisites check + Assert.True(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + Assert.Same(interval, quantizer.CurrentInterval); + + // Execute + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance:false); + + // Test + Assert.True(position.HasValue); + Assert.True(position.IsExact); + Assert.Same(interval, quantizer.CurrentInterval); + Assert.True(quantizer.CurrentInterval!.DateTimeOffset < position.DateTimeOffset); + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithAdvanceAndWithDefaultRef_AdvancesCurrentIntervalAndRaisesEvent() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var isAdvancedEventRaised = false; + + // Pre-requisites check + Assert.True(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + Assert.Same(interval, quantizer.CurrentInterval); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: true); + + // Test + Assert.True(position.HasValue); + Assert.True(position.IsExact); + Assert.NotSame(interval, quantizer.CurrentInterval); + Assert.True(quantizer.CurrentInterval!.DateTimeOffset == position.DateTimeOffset); + + Assert.True(isAdvancedEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + isAdvancedEventRaised = true; + } + } + + [Fact] + public void ClockQuantizer_EnsureInitializedExactTimeSerialPosition_WithAdvanceAndWithDefaultRef_DoesNotGetBrokenByInteractionInAdvancedEvent() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var isAdvancedEventRaised = false; + + // Pre-requisites check + Assert.True(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + Assert.Same(interval, quantizer.CurrentInterval); + + quantizer.Advanced += Quantizer_Advanced; + + // Execute + var position = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref position, advance: true); + + // Test + Assert.True(position.HasValue); + Assert.True(position.IsExact); + Assert.NotSame(interval, quantizer.CurrentInterval); + + // quantizer.CurrentInterval advanced once more in the event handler, so we cannot validate position.DateTimeOffset against it + Assert.False(quantizer.CurrentInterval!.DateTimeOffset == position.DateTimeOffset); + + Assert.True(isAdvancedEventRaised); + + void Quantizer_Advanced(object? sender, ClockQuantizer.NewIntervalEventArgs e) + { + var recurse = !isAdvancedEventRaised; + isAdvancedEventRaised = true; + + // Let's see if we can break the advance/serial logic + var currentInterval = quantizer.CurrentInterval; + + // Test that quantizer.CurrentInterval already advanced + Assert.NotNull(currentInterval); + Assert.NotSame(interval, currentInterval); + + var interferingTimeSerialPosition1 = default(LazyTimeSerialPosition); + Interval.EnsureInitializedTimeSerialPosition(interval, ref interferingTimeSerialPosition1); + Assert.True(interferingTimeSerialPosition1.HasValue); + + var interferingTimeSerialPosition2 = currentInterval!.NewTimeSerialPosition(); + Assert.True(interferingTimeSerialPosition2.HasValue); + + var interferingTimeSerialPosition3 = default(LazyTimeSerialPosition); + quantizer.EnsureInitializedExactTimeSerialPosition(ref interferingTimeSerialPosition3, advance: recurse /* let's not recurse more than once... */); + Assert.True(interferingTimeSerialPosition3.IsExact); + } + } + } +} diff --git a/tests/ClockQuantization.Tests/IntervalTests.cs b/tests/ClockQuantization.Tests/IntervalTests.cs new file mode 100644 index 0000000..7528a25 --- /dev/null +++ b/tests/ClockQuantization.Tests/IntervalTests.cs @@ -0,0 +1,170 @@ +using ClockQuantization.Tests.Assets; +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Xunit; + +namespace ClockQuantization.Tests +{ + public class IntervalTests + { + [Fact] + public void Interval_NewTimeSerialPosition_ByDefinitionCannotBeExact() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + // Execute + var position = interval.NewTimeSerialPosition(); + + // A position acquired after the creation of an interval *by definition* can never be exact + + // Test + Assert.True(position.HasValue); + Assert.False(position.IsExact); + } + + [Fact] + public void Interval_EnsureInitializedTimeSerialPosition_ByDefinitionCannotBeExact() + { + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + // Execute + var position = default(LazyTimeSerialPosition); + Interval.EnsureInitializedTimeSerialPosition(interval, ref position); + + // A position acquired after the creation of an interval *by definition* can never be exact + + // Test + Assert.True(position.HasValue); + Assert.False(position.IsExact); + } + + [Fact] + public void Interval_ConsecutivelyAcquiredTimeSerialPositionsAreIssuedNonStrictlyMonotonically() + { + const int positionCount = 100; + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + // Execute + var sequence = new List(positionCount); + for (var i = 0; i < positionCount; i++) + { + var position = default(LazyTimeSerialPosition); + Interval.EnsureInitializedTimeSerialPosition(interval, ref position); + sequence.Add(position.SerialPosition); + + Assert.Equal(interval.DateTimeOffset, position.DateTimeOffset); + } + + // Test + for (var i = 1; i < positionCount; i++) + { + Assert.True(sequence[i] >= sequence[i - 1]); + } + } + + + [Fact] + public void Interval_ConcurrentlyAcquiredTimeSerialPositionsAreIssuedNonStrictlyMonotonically() + { + const int positionPerPartitionCount = 16 * 1024; + const int partitionCount = 16; + var metronomeOptions = MetronomeOptions.Manual; + var now = DateTimeOffset.UtcNow; + var context = new SystemClockTemporalContext(now, metronomeOptions); + + // Pre-requisites check + Assert.False(context.HasExternalClock); + + var quantizer = new ClockQuantizer(context, metronomeOptions.MaxIntervalTimeSpan); + var interval = quantizer.Advance(); + + // Execute + const int sampleCount = partitionCount * positionPerPartitionCount; + var stringOfPerRangeSequences = new uint[sampleCount]; + List> ranges = new List>(); + + int failedPerRangeSequenceCount = 0; + + Parallel.ForEach(Partitioner.Create(0, sampleCount, positionPerPartitionCount), + (range) => + { + lock (ranges) + { + ranges.Add(range); + } + + // Execute + for (var i = range.Item1; i < range.Item2; i++) + { + var position = default(LazyTimeSerialPosition); + Interval.EnsureInitializedTimeSerialPosition(interval, ref position); + stringOfPerRangeSequences[i] = position.SerialPosition; + } + + // Test: monotonically increasing within this partition + for (var i = range.Item1 + 1; i < range.Item2; i++) + { + if (stringOfPerRangeSequences[i] < stringOfPerRangeSequences[i - 1]) + { + Interlocked.Increment(ref failedPerRangeSequenceCount); + break; + } + } + }); + + // Verify that clock quantizer didn't advance while weren't looking... + Assert.Same(interval, quantizer.CurrentInterval); + + // Verify that partitions did not overlap and were strictly consecutive. + ranges.Sort((t1, t2) => t1.Item1.CompareTo(t2.Item1)); + var previousRange = default(Tuple); + foreach (var range in ranges) + { + if (previousRange != null) + { + Assert.True(previousRange.Item2 == range.Item1); + } + previousRange = range; + } + + Assert.Equal(0, failedPerRangeSequenceCount); + + // Some tests across partitions (concurrent acquisition of serials) + foreach (var range in ranges) + { + // Check lowest number in each partition + Assert.True(stringOfPerRangeSequences[range.Item1] > 1); // First serial issued after creating sealed interval == 2 + + // Check highest number in each partition + Assert.True(stringOfPerRangeSequences[range.Item2 - 1] <= sampleCount + 1); + } + } + } +} diff --git a/tests/ClockQuantization.Tests/assets/MetronomeOptions.cs b/tests/ClockQuantization.Tests/assets/MetronomeOptions.cs new file mode 100644 index 0000000..4578e4a --- /dev/null +++ b/tests/ClockQuantization.Tests/assets/MetronomeOptions.cs @@ -0,0 +1,32 @@ +using System; + +namespace ClockQuantization.Tests.Assets +{ + class MetronomeOptions + { + public static readonly MetronomeOptions Default = new MetronomeOptions + { + MaxIntervalTimeSpan = TimeSpan.FromMinutes(1), + IsManual = false, + StartSuspended = true, + }; + + public static readonly MetronomeOptions Manual = new MetronomeOptions + { + MaxIntervalTimeSpan = TimeSpan.FromMinutes(1), + IsManual = true, + StartSuspended = true, + }; + + public static readonly MetronomeOptions Automatic = new MetronomeOptions + { + MaxIntervalTimeSpan = TimeSpan.FromMinutes(1), + IsManual = false, + StartSuspended = false, + }; + + public TimeSpan MaxIntervalTimeSpan { get; set; } + public bool IsManual { get; set; } + public bool StartSuspended { get; set; } + } +} diff --git a/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs b/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs new file mode 100644 index 0000000..da994fc --- /dev/null +++ b/tests/ClockQuantization.Tests/assets/SystemClockTemporalContext.cs @@ -0,0 +1,163 @@ +using System; + +namespace ClockQuantization.Tests.Assets +{ + /// + /// Implements a test clock, as well as provides a compatible to observers. + /// + class SystemClockTemporalContext : ISystemClock, ISystemClockTemporalContext + { + private class ManualClock : ISystemClock + { + private DateTimeOffset _now; + public DateTimeOffset UtcNow { get => _now; } + + public ManualClock(DateTimeOffset now) + { + _now = now; + } + + public void Add(TimeSpan timeSpan) + { + _now = _now + timeSpan; + } + public void AdjustClock(DateTimeOffset now) + { + _now = now; + } + } + + private ManualClock? _manual; + public bool HasExternalClock => _manual is null; + public DateTimeOffset UtcNow => GetUtcNow(); + + private System.Threading.Timer? _metronome; + private MetronomeOptions? _metronomeOptions; + + public bool ProvidesMetronome { get; private set; } + public bool IsMetronomeRunning { get; private set; } + + public event EventHandler? ClockAdjusted; + public event EventHandler? MetronomeTicked; + + private Func GetUtcNow; + + /// + /// Creates a test clock without metronome that is linked to the system clock + /// + public SystemClockTemporalContext() : this(() => DateTimeOffset.UtcNow, metronomeOptions: null) { } + + public SystemClockTemporalContext(ISystemClock clock, MetronomeOptions? metronomeOptions) : this(() => clock.UtcNow, metronomeOptions) + { + if (clock is ManualClock manual) + { + _manual = manual; + } + } + + /// + /// Creates a manual test clock, with or without metronome. will have an initial value of . + /// + /// + /// + public SystemClockTemporalContext(DateTimeOffset now, MetronomeOptions? metronomeOptions) : this(new ManualClock(now), metronomeOptions) { } + + /// + /// Creates a manual test clock, with or without metronome. will have an initial value of new DateTime(2013, 6, 15, 12, 34, 56, 789). + /// + /// + public SystemClockTemporalContext(MetronomeOptions? metronomeOptions) : this(new DateTime(2013, 6, 15, 12, 34, 56, 789), metronomeOptions) { } + + /// + /// Creates a test clock with or without metronome. + /// + /// A representing the canonical clock function + /// + public SystemClockTemporalContext(Func getUtcNow, MetronomeOptions? metronomeOptions) + { + GetUtcNow = getUtcNow; + ProvidesMetronome = (_metronomeOptions = metronomeOptions) is not null; + IsMetronomeRunning = ProvidesMetronome && ApplyMetronomeOptions(metronomeOptions!, Metronome_Ticked, out _metronome); + } + + private bool ApplyMetronomeOptions(MetronomeOptions metronomeOptions, System.Threading.TimerCallback callback, out System.Threading.Timer? metronome) + { + metronome = null; + + if (!metronomeOptions.IsManual) + { + if (metronomeOptions.MaxIntervalTimeSpan.Negate() >= metronomeOptions.MaxIntervalTimeSpan) + { + throw new ArgumentOutOfRangeException(nameof(metronomeOptions.MaxIntervalTimeSpan), $"Value must be greater than TimeSpan.Zero"); + } + var running = !metronomeOptions.StartSuspended; + metronome = new System.Threading.Timer(callback, null, running ? TimeSpan.Zero : System.Threading.Timeout.InfiniteTimeSpan, metronomeOptions.MaxIntervalTimeSpan); + + return running; + } + + return false; + } + + + + protected void OnClockAdjusted(EventArgs e) => ClockAdjusted?.Invoke(this, e); + protected void OnMetronomeTicked(EventArgs e) => MetronomeTicked?.Invoke(this, e); + + private void Metronome_Ticked(object? state) + { + // For manual clocks with built-in metronome, we must first update _now + _manual?.Add(_metronomeOptions!.MaxIntervalTimeSpan); + + OnMetronomeTicked(EventArgs.Empty); + } + + public void FireMetronomeTicked(DateTimeOffset now) + { + if (_metronome is not null || _manual is null) throw new InvalidOperationException(); + if (now < _manual.UtcNow) throw new ArgumentOutOfRangeException(nameof(now), $"Value compared to clock reference is in the past."); + + _manual.AdjustClock(now); + OnMetronomeTicked(EventArgs.Empty); + } + + public void FireMetronomeTicked() + { + if (_metronome is not null) throw new InvalidOperationException(); + + OnMetronomeTicked(EventArgs.Empty); + } + + public void AdjustClock(DateTimeOffset now) + { + if (_manual is null) throw new InvalidOperationException(); + + _manual.AdjustClock(now); + + OnClockAdjusted(EventArgs.Empty); + } + + public void Add(TimeSpan timeSpan) + { + if (_manual is null) throw new InvalidOperationException(); + + AdjustClock(_manual.UtcNow + timeSpan); + } + + public void SuspendMetronome() + { + if (_metronome is null || !IsMetronomeRunning) throw new InvalidOperationException(); + + _metronome.Change(System.Threading.Timeout.InfiniteTimeSpan, _metronomeOptions!.MaxIntervalTimeSpan); + IsMetronomeRunning = false; + } + + public void ResumeMetronome() + { + if (_metronome is null || IsMetronomeRunning) throw new InvalidOperationException(); + + IsMetronomeRunning = true; + _metronome.Change(TimeSpan.Zero, _metronomeOptions!.MaxIntervalTimeSpan); + } + } +} diff --git a/tests/ClockQuantization.Tests/assets/assumptions/SystemClockTemporalContextAssumptions.cs b/tests/ClockQuantization.Tests/assets/assumptions/SystemClockTemporalContextAssumptions.cs new file mode 100644 index 0000000..32be0a4 --- /dev/null +++ b/tests/ClockQuantization.Tests/assets/assumptions/SystemClockTemporalContextAssumptions.cs @@ -0,0 +1,520 @@ +using ClockQuantization.Tests.Assets; +using System; +using Xunit; + + +namespace ClockQuantization.Assumptions +{ + /// + /// This set of tests verifies a couple of basic assumptions about the + /// to ensure that tests leveraging it can rely on its expected behavior. Consider it a poor-man's + /// way of documenting the clock driver's behaviour 😎. + /// + public class SystemClockTemporalContextAssumptions + { + [Fact] + public void TestClock_Add_RaisesClockAdjustedEventAndAdjustsUtcNow() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + var timeSpan = TimeSpan.FromMinutes(1); + var clockAdjustedEventRaised = false; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute + context.ClockAdjusted += Context_ClockAdjusted; + context.Add(timeSpan); + + // Test + Assert.True(clockAdjustedEventRaised); + + void Context_ClockAdjusted(object? sender, EventArgs e) + { + // Test + Assert.Equal(now + timeSpan, context.UtcNow); + clockAdjustedEventRaised = true; + } + } + + [Fact] + public void TestClock_AdjustClock_RaisesClockAdjustedEventAndAdjustsUtcNow() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow + TimeSpan.FromMinutes(1); + var clockAdjustedEventRaised = false; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute + context.ClockAdjusted += Context_ClockAdjusted; + context.AdjustClock(now); + + // Test + Assert.True(clockAdjustedEventRaised); + + void Context_ClockAdjusted(object? sender, EventArgs e) + { + // Test + Assert.Equal(now, context.UtcNow); + clockAdjustedEventRaised = true; + } + } + + [Fact] + public void TestClock_WithManualMetronome_FireMetronomeTicked_RaisesMetronomeTickedEventAndDoesNotAdjustUtcNow() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + var metronomeTickedEventRaised = false; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + context.FireMetronomeTicked(now); + + // Test + Assert.True(metronomeTickedEventRaised); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + // Test that UtcNow was *not* adjusted + Assert.Equal(now, context.UtcNow); + metronomeTickedEventRaised = true; + } + } + + [Fact] + public void TestClock_WithManualMetronome_FireMetronomeTickedWithFutureDateTimeOffset_RaisesMetronomeTickedEventAndAdjustsUtcNow() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = DateTimeOffset.UtcNow; // note, not the clock's UtcNow! + var metronomeTickedEventRaised = false; + + // Test pre-requisite checks + Assert.False(context.HasExternalClock); + Assert.NotEqual(now, context.UtcNow); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + context.FireMetronomeTicked(now); + + // Test + Assert.True(metronomeTickedEventRaised); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + // Test that UtcNow was already updated + Assert.Equal(now, context.UtcNow); + metronomeTickedEventRaised = true; + } + } + + [Fact] + public void TestClock_WithManualMetronome_FireMetronomeTickedWithFutureDateTimeOffset_DoesNotRaiseClockAdjustedEvent() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = DateTimeOffset.UtcNow; // note, not the clock's UtcNow! + var visited = new System.Threading.ManualResetEvent(false); + + // Test pre-requisite checks + Assert.False(context.HasExternalClock); + Assert.NotEqual(now, context.UtcNow); + + // Execute + context.ClockAdjusted += Context_ClockAdjusted; + context.FireMetronomeTicked(now); + + // Test that the test clock will update UtcNow, but not raise the ClockAdjusted event + Assert.False(visited.WaitOne(metronomeOptions.MaxIntervalTimeSpan + TimeSpan.FromMilliseconds(250))); + Assert.Equal(now, context.UtcNow); + + void Context_ClockAdjusted(object? sender, EventArgs e) + { + // We should not get here... + visited.Set(); + } + } + + [Fact] + public void TestClock_WithManualMetronome_FireMetronomeTickedWithPastDateTimeOffset_ThrowsArgumentOutOfRangeException() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute & test + var offset = now + TimeSpan.FromSeconds(-5); + Assert.Throws(() => context.FireMetronomeTicked(offset)); + + // Test internal assumption that the test clock will *not* update UtcNow + Assert.Equal(now, context.UtcNow); + } + + [Fact] + public void ExternalClock_Add_ThrowsInvalidOperationException() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var timeSpan = TimeSpan.FromMinutes(1); + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute & tests + Assert.Throws(() => context.Add(timeSpan)); + } + + [Fact] + public void ExternalClock_AdjustClock_ThrowsInvalidOperationException() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var now = context.UtcNow + TimeSpan.FromMinutes(1); + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute & tests + Assert.Throws(() => context.AdjustClock(now)); + } + + [Fact] + public void ExternalClock_WithManualMetronome_FireMetronomeTickedWithFutureDateTimeOffset_ThrowsInvalidOperationException() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var now = context.UtcNow; + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute & test + var offset = now + TimeSpan.FromSeconds(5); + Assert.Throws(() => context.FireMetronomeTicked(offset)); + } + + [Fact] + public void ExternalClock_WithManualMetronome_FireMetronomeTickedWithPastDateTimeOffset_ThrowsInvalidOperationException() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var now = context.UtcNow; + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute & test + var offset = now + TimeSpan.FromSeconds(-5); + Assert.Throws(() => context.FireMetronomeTicked(offset)); + } + + [Fact] + public void ExternalClock_WithManualMetronome_FireMetronomeTicked_RaisesMetronomeTickedEvent() + { + var metronomeOptions = MetronomeOptions.Manual; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var metronomeTickedEventRaised = false; + var start = context.UtcNow; + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + context.FireMetronomeTicked(); + + // Test + Assert.True(metronomeTickedEventRaised); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + metronomeTickedEventRaised = true; + + // Validate that clock has advanced + Assert.True(context.UtcNow > start); + } + } + + [Fact] + public void ExternalClock_WithRunningTemporalContextMetronome_RaisesMetronomeTickedEvent() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var start = context.UtcNow; + var visited = new System.Threading.ManualResetEvent(false); + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + context.ResumeMetronome(); + + // Test + Assert.True(context.IsMetronomeRunning); + Assert.True(visited.WaitOne(metronomeOptions.MaxIntervalTimeSpan + TimeSpan.FromMilliseconds(50))); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + context.SuspendMetronome(); + + // Validate that clock has advanced + Assert.True(context.UtcNow > start); + + visited.Set(); + } + } + + [Fact] + public void ExternalClock_WithSuspendedTemporalContextMetronome_DoesNotRaiseMetronomeTickedEvent() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + var start = context.UtcNow; + var visited = new System.Threading.ManualResetEvent(false); + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + + // Test + Assert.False(context.IsMetronomeRunning); + Assert.False(visited.WaitOne(metronomeOptions.MaxIntervalTimeSpan + TimeSpan.FromMilliseconds(250))); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + // We should not get here... + visited.Set(); + } + } + + [Fact] + public void ExternalClock_WithSuspendedTemporalContextMetronome_SuspendMetronome_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute & test + Assert.False(context.IsMetronomeRunning); + Assert.Throws(() => context.SuspendMetronome()); + } + + [Fact] + public void ExternalClock_WithRunningTemporalContextMetronome_ResumeMetronome_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = false, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(() => DateTimeOffset.UtcNow, metronomeOptions); + + // Test pre-requisite check + Assert.True(context.HasExternalClock); + + // Execute & test + Assert.True(context.IsMetronomeRunning); + Assert.Throws(() => context.ResumeMetronome()); + } + + [Fact] + public void TestClock_WithRunningTemporalContextMetronome_AdjustsUtcNowAndRaisesMetronomeTickedEvent() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + var start = context.UtcNow; + var visited = new System.Threading.ManualResetEvent(false); + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + context.ResumeMetronome(); + + // Test + Assert.True(context.IsMetronomeRunning); + Assert.True(visited.WaitOne(metronomeOptions.MaxIntervalTimeSpan + TimeSpan.FromMilliseconds(50))); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + context.SuspendMetronome(); + + // Validate that clock has advanced + Assert.True(context.UtcNow > start); + + visited.Set(); + } + } + + [Fact] + public void TestClock_WithSuspendedTemporalContextMetronome_DoesNotRaiseMetronomeTickedEvent() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + var start = context.UtcNow; + var visited = new System.Threading.ManualResetEvent(false); + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute + context.MetronomeTicked += Context_MetronomeTicked; + + // Test + Assert.False(context.IsMetronomeRunning); + Assert.False(visited.WaitOne(metronomeOptions.MaxIntervalTimeSpan + TimeSpan.FromMilliseconds(250))); + + void Context_MetronomeTicked(object? sender, EventArgs e) + { + // We should not get here... + visited.Set(); + } + } + + [Fact] + public void TestClock_WithSuspendedTemporalContextMetronome_SuspendMetronome_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute & test + Assert.False(context.IsMetronomeRunning); + Assert.Throws(() => context.SuspendMetronome()); + } + + [Fact] + public void TestClock_WithRunningTemporalContextMetronome_ResumeMetronome_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = false, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute & test + Assert.True(context.IsMetronomeRunning); + Assert.Throws(() => context.ResumeMetronome()); + } + + [Fact] + public void TestClock_WithTemporalContextMetronome_FireMetronomeTicked_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute & test + Assert.Throws(() => context.FireMetronomeTicked()); + } + + [Fact] + public void TestClock_WithTemporalContextMetronome_FireMetronomeTickedWithFutureDateTimeOffset_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute & test + var offset = now + TimeSpan.FromSeconds(5); + Assert.Throws(() => context.FireMetronomeTicked(offset)); + } + + [Fact] + public void TestClock_WithTemporalContextMetronome_FireMetronomeTickedWithPastDateTimeOffset_ThrowsInvalidOperationException() + { + var metronomeOptions = new MetronomeOptions + { + IsManual = false, + StartSuspended = true, + MaxIntervalTimeSpan = TimeSpan.FromMilliseconds(5), + }; + var context = new SystemClockTemporalContext(metronomeOptions); + var now = context.UtcNow; + + // Test pre-requisite check + Assert.False(context.HasExternalClock); + + // Execute & test + var offset = now + TimeSpan.FromSeconds(-5); + Assert.Throws(() => context.FireMetronomeTicked(offset)); + } + } +}