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));
+ }
+ }
+}