diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/ClockQuantization/ClockQuantizer.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/ClockQuantization/ClockQuantizer.cs
new file mode 100644
index 0000000000000..f027264713d47
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/ClockQuantization/ClockQuantizer.cs
@@ -0,0 +1,414 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+
+namespace Microsoft.Extensions.Internal.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.
+ internal class ClockQuantizer : IAsyncDisposable, IDisposable
+ {
+ private readonly TemporalContextDriver _driver;
+ private Interval? _currentInterval;
+
+
+ #region Fields & 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; }
+
+ ///
+ /// Represents the clock-specific offset at which the next event is expected.
+ ///
+ ///
+ /// While uninitialized initially, will always have a value after the first advance operation. Basically, having
+ /// CurrentInterval.ClockOffset + TimeSpanToClockOffsetUnits(MaxIntervalTimeSpan) pre-calculated at the start of each metronomic interval, ammortizes the cost of this typical calculation during time-based decisions.
+ /// When an "out-of-cadance" (i.e. non-metronomic) advance operation is performed, (and its offset) will update, but not .
+ ///
+ public long? NextMetronomicClockOffset { get; private set; }
+
+
+ /// 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 => _driver.UtcNow; }
+
+ /// Returns the value of the reference clock.
+ public long UtcNowClockOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _driver.UtcNowClockOffset; }
+
+ #endregion
+
+
+ #region Time representation conversions
+
+ ///
+ /// Converts a to an offset in clock-specific units (ticks).
+ ///
+ /// The to convert
+ /// An offset in clock-specific units.
+ ///
+ public long DateTimeOffsetToClockOffset(DateTimeOffset offset) => _driver.DateTimeOffsetToClockOffset(offset);
+
+ ///
+ /// Converts an offset in clock-specific units (ticks) to a .
+ ///
+ /// The clock-specific offset to convert
+ /// A in UTC.
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public DateTimeOffset ClockOffsetToUtcDateTimeOffset(long offset) => _driver.ClockOffsetToUtcDateTimeOffset(offset);
+
+ ///
+ /// Converts a to a count of clock-specific offset units (ticks).
+ ///
+ /// The to convert
+ /// The amount of clock-specific offset units covering the .
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public long TimeSpanToClockOffsetUnits(TimeSpan timeSpan) => (long)(timeSpan.TotalMilliseconds * _driver.ClockOffsetUnitsPerMillisecond);
+
+ ///
+ /// Converts an amount of clock-specific offset units (ticks) to a .
+ ///
+ /// The amount of units to convert
+ /// A covering the specified number of .
+ public TimeSpan ClockOffsetUnitsToTimeSpan(long units) => TimeSpan.FromMilliseconds((double)units / _driver.ClockOffsetUnitsPerMillisecond);
+
+ #endregion
+
+
+ #region Basic quantizer & clock-offset-serial position 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);
+
+ ///
+ /// 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 EnsureInitializedExactClockOffsetSerialPosition(ref LazyClockOffsetSerialPosition 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.EnsureInitializedClockOffsetSerialPosition(preparation.Interval, ref position);
+ CommitAdvance(preparation);
+ }
+ else
+ {
+ Interval.EnsureInitializedClockOffsetSerialPosition(NewDisconnectedInterval(), ref position);
+ }
+ }
+ }
+
+ ///
+ /// If does not have an yet, it will be initialized with one.
+ ///
+ /// Reference to an (on-stack) which may or may not have been initialized.
+ ///
+ /// If the had not performed 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 EnsureInitializedClockOffsetSerialPosition(ref LazyClockOffsetSerialPosition position)
+ {
+ if (!position.HasValue)
+ {
+ Interval.EnsureInitializedClockOffsetSerialPosition(_currentInterval ?? NewDisconnectedInterval(), ref position);
+ }
+ }
+
+ #endregion
+
+
+ #region 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);
+
+ #endregion
+
+
+ // Construction
+
+ ///
+ /// Creates a new instance.
+ ///
+ /// The reference
+ /// The maximum of each
+ ///
+ /// If also implements , the will pick up on external
+ /// events. Also, if is non-,
+ /// the will pick up on external events, instead of relying on an internal metronome.
+ ///
+ public ClockQuantizer(ISystemClock clock, TimeSpan maxIntervalTimeSpan)
+ {
+ _driver = new TemporalContextDriver(clock, MaxIntervalTimeSpan = maxIntervalTimeSpan);
+ _driver.ClockAdjusted += Driver_ClockAdjusted;
+ _driver.MetronomeTicked += Driver_MetronomeTicked;
+ }
+
+
+ // Quiescing
+
+ private readonly object _quiescingLockObject = new object();
+
+ ///
+ /// Puts the into a quiescent state, effectively freeing any owned unmanaged resources. While in a quiescent state, the will not raise any events, nor perform metronomic advance operations.
+ ///
+ ///
+ /// Any externally initiated advance operation will automatically take the back into normal operation.
+ ///
+ public void Quiesce()
+ {
+ // Ensure that quiesent state can be achieved without immediately being knocked out of it by a MetronomeTicked event that just happened to be in flight.
+ lock (_quiescingLockObject)
+ {
+ _driver.Quiesce();
+ }
+ }
+
+ ///
+ /// Takes the out of a quiescent state into normal operation.
+ ///
+ public void Unquiesce() => _driver.Unquiesce();
+
+ ///
+ /// Returns if the is in a quiescent state, otherwise.
+ ///
+ public bool IsQuiescent { get => _driver.IsQuiescent; }
+
+
+ // Advance primitives
+
+ private struct AdvancePreparationInfo
+ {
+ public Interval Interval;
+ public ClockQuantizer.NewIntervalEventArgs EventArgs;
+
+ public AdvancePreparationInfo(Interval interval, ClockQuantizer.NewIntervalEventArgs eventArgs)
+ {
+ Interval = interval;
+ EventArgs = eventArgs;
+ }
+ }
+
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ private Interval NewDisconnectedInterval() => new Interval(UtcNowClockOffset);
+
+ 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.
+ bool unquiescing = _driver.Unquiesce();
+ if (unquiescing || _currentInterval is null)
+ {
+ metronomic = true;
+ }
+
+ 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 (unquiescing || !metronomic || (metronomic && !_driver.HasInternalMetronome))
+ {
+ var gap = ClockOffsetUnitsToTimeSpan(interval.ClockOffset - previousInterval.ClockOffset) - MaxIntervalTimeSpan;
+ if (gap > TimeSpan.Zero)
+ {
+ detectedGap = gap;
+ }
+ }
+ }
+
+ var e = new NewIntervalEventArgs(_driver.ClockOffsetToUtcDateTimeOffset(interval.ClockOffset), metronomic, detectedGap);
+
+ return new AdvancePreparationInfo(interval, e);
+ }
+
+ private Interval CommitAdvance(AdvancePreparationInfo preparation)
+ {
+ _currentInterval = preparation.Interval.Seal();
+
+ var e = preparation.EventArgs;
+ if (e.IsMetronomic)
+ {
+ NextMetronomicClockOffset = _driver.DateTimeOffsetToClockOffset(e.DateTimeOffset + MaxIntervalTimeSpan);
+ }
+
+ OnAdvanced(e);
+
+ if (e.IsMetronomic)
+ {
+ OnMetronomeTicked(e);
+ }
+
+ return preparation.Interval;
+ }
+
+ private void Driver_MetronomeTicked(object? _, EventArgs __)
+ {
+ lock (_quiescingLockObject)
+ {
+ // Ensure that any in-flight MetronomeTicked event during quiescing transition does not knock us out of a quiesent state that was juuuuuuust established.
+ if (!IsQuiescent)
+ {
+ Advance(metronomic: true);
+ }
+ }
+ }
+
+ private void Driver_ClockAdjusted(object? _, EventArgs __)
+ {
+ // Allow clock adjustemts to take us out of quiesent state (race possible; small chance of in-flight event, as underlying driver is quisced as well).
+ Advance(metronomic: false);
+ }
+
+
+ #region IAsyncDisposable/IDisposable
+
+ private int _areEventHandlersDetached;
+ private void DetachEventHandlers()
+ {
+ if (Interlocked.CompareExchange(ref _areEventHandlersDetached, 1, 0) == 0)
+ {
+ _driver.ClockAdjusted -= Driver_ClockAdjusted;
+ _driver.MetronomeTicked -= Driver_MetronomeTicked;
+ }
+ }
+
+ ///
+ public void Dispose()
+ {
+ // This method is re-entrant
+ DetachEventHandlers();
+
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ // This method is re-entrant
+ DetachEventHandlers();
+
+ await DisposeAsyncCore().ConfigureAwait(false);
+
+ Dispose(disposing: false);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // This method is re-entrant and mutually co-existent with _driver.DisposeAsyncCore()
+ _driver.Dispose();
+ }
+ }
+
+ protected virtual async ValueTask DisposeAsyncCore()
+ {
+ // This method is re-entrant and mutually co-existent with _driver.Dispose()
+ await _driver.DisposeAsync().ConfigureAwait(false);
+ }
+
+ #endregion
+ }
+}
+
+#nullable restore
+
diff --git a/src/libraries/Microsoft.Extensions.Caching.Memory/src/ClockQuantization/TemporalContextDriver.cs b/src/libraries/Microsoft.Extensions.Caching.Memory/src/ClockQuantization/TemporalContextDriver.cs
new file mode 100644
index 0000000000000..349210c6dd0ec
--- /dev/null
+++ b/src/libraries/Microsoft.Extensions.Caching.Memory/src/ClockQuantization/TemporalContextDriver.cs
@@ -0,0 +1,280 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+
+#nullable enable
+
+using System;
+using System.Runtime.CompilerServices;
+using System.Threading;
+using System.Threading.Tasks;
+
+
+namespace Microsoft.Extensions.Internal.ClockQuantization
+{
+ // Isolate some of the context/metronome madness from the core ClockQuantizer implementation
+ internal class TemporalContextDriver : ClockQuantization.ISystemClock, ISystemClockTemporalContext, IDisposable, IAsyncDisposable
+ {
+ private readonly ClockQuantization.ISystemClock _clock;
+ private readonly TimeSpan _metronomeIntervalTimeSpan;
+ private System.Threading.Timer? _metronome;
+ private EventArgs? _pendingClockAdjustedEventArgs;
+
+ public TemporalContextDriver(ISystemClock clock, TimeSpan? metronomeIntervalTimeSpan = null)
+ {
+ AttachExternalTemporalContext(this, clock, out var externalMetronomeIntervalTimeSpan, out var clockIsManual);
+
+ if (externalMetronomeIntervalTimeSpan.HasValue)
+ {
+ IsQuiescent = false;
+ _metronomeIntervalTimeSpan = externalMetronomeIntervalTimeSpan.Value;
+ }
+ else
+ {
+ if (!metronomeIntervalTimeSpan.HasValue)
+ {
+ // Need metronome interval timespan
+ DetachExternalTemporalContext();
+ throw new ArgumentNullException(nameof(metronomeIntervalTimeSpan), "Must provide a valid metronome interval timespan or supply an external metronome.");
+ }
+
+ _metronomeIntervalTimeSpan = metronomeIntervalTimeSpan.Value;
+ }
+
+ _clock = clock;
+ ClockIsManual = clockIsManual;
+
+ static void AttachExternalTemporalContext(TemporalContextDriver driver, ISystemClock clock, out TimeSpan? externalMetronomeIntervalTimeSpan, out bool clockIsManual)
+ {
+ externalMetronomeIntervalTimeSpan = null;
+ clockIsManual = default;
+
+ if (clock is ISystemClockTemporalContext context)
+ {
+ context.ClockAdjusted += driver.Context_ClockAdjusted;
+ if (context.MetronomeIntervalTimeSpan.HasValue)
+ {
+ externalMetronomeIntervalTimeSpan = context.MetronomeIntervalTimeSpan.Value;
+ // Allow external "pulse" on metronome ticks
+ context.MetronomeTicked += driver.Context_MetronomeTicked;
+ }
+
+ clockIsManual = context.ClockIsManual;
+ }
+ }
+ }
+
+ private int _isExternalTemporalContextDetached;
+ private void DetachExternalTemporalContext()
+ {
+ if (Interlocked.CompareExchange(ref _isExternalTemporalContextDetached, 1, 0) == 0)
+ {
+ if (_clock is ISystemClockTemporalContext context)
+ {
+ context.ClockAdjusted -= Context_ClockAdjusted;
+ if (context.MetronomeIntervalTimeSpan.HasValue)
+ {
+ context.MetronomeTicked -= Context_MetronomeTicked;
+ }
+ }
+ }
+ }
+
+ public bool ClockIsManual { get; private set; }
+
+ public bool HasInternalMetronome
+ {
+ get => _clock is not ISystemClockTemporalContext context || !context.MetronomeIntervalTimeSpan.HasValue;
+ }
+
+ private void EnsureInternalMetronome()
+ {
+ if (_metronome is null && HasInternalMetronome)
+ {
+ // Create a paused metronome timer
+ var metronome = new Timer(Metronome_TimerCallback, null, Timeout.InfiniteTimeSpan, _metronomeIntervalTimeSpan);
+ if (Interlocked.CompareExchange(ref _metronome, metronome, null) is null)
+ {
+ // Resume the newly created metronome timer
+ metronome.Change(_metronomeIntervalTimeSpan, _metronomeIntervalTimeSpan);
+ }
+ else
+ {
+ // Wooops... another thread outpaced us...
+ metronome.Dispose();
+ }
+ }
+ }
+
+ private void DisposeInternalMetronome()
+ {
+ if (Interlocked.Exchange(ref _metronome, null) is Timer metronome)
+ {
+ metronome.Dispose();
+ }
+ }
+
+ private async ValueTask DisposeInternalMetronomeAsync()
+ {
+#if !(NETSTANDARD2_1 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET5_0 || NET5_0_OR_GREATER)
+ await default(ValueTask).ConfigureAwait(continueOnCapturedContext: false);
+#endif
+
+ if (Interlocked.Exchange(ref _metronome, null) is Timer metronome)
+ {
+#if NETSTANDARD2_1 || NETCOREAPP3_0 || NETCOREAPP3_1 || NET5_0 || NET5_0_OR_GREATER
+ if (metronome is IAsyncDisposable asyncDisposable)
+ {
+ await asyncDisposable.DisposeAsync().ConfigureAwait(continueOnCapturedContext: false);
+ return;
+ }
+#endif
+ metronome.Dispose();
+ }
+ }
+
+ public bool IsQuiescent { get; private set; } = true;
+
+ public void Quiesce()
+ {
+ IsQuiescent = true;
+
+ // Dispose of internal metronome, if applicable.
+ DisposeInternalMetronome();
+ }
+
+ private readonly object _unquiescingLockObject = new object();
+ public bool Unquiesce()
+ {
+ bool unquiescing = IsQuiescent;
+
+ if (unquiescing)
+ {
+ EventArgs? pendingClockAdjustedEventArgs = Interlocked.Exchange(ref _pendingClockAdjustedEventArgs, null);
+
+ if (pendingClockAdjustedEventArgs is not null)
+ {
+ // Make sure that we briefly postpone any metronome event that may occur during the process of unquiescing
+ lock (_unquiescingLockObject)
+ {
+ IsQuiescent = false; // Set to false already to make sure that the ClockAdjusted event fires
+ OnClockAdjusted(pendingClockAdjustedEventArgs);
+ }
+ }
+
+ IsQuiescent = false;
+ }
+
+ // Ensure that we have an internal metronome, if applicable.
+ EnsureInternalMetronome();
+
+ return unquiescing;
+ }
+
+ protected virtual void OnMetronomeTicked(EventArgs e)
+ {
+ if (!IsQuiescent)
+ {
+ // Make sure that we briefly postpone a metronome event that occurs during the process of unquiescing
+ lock (_unquiescingLockObject)
+ {
+ MetronomeTicked?.Invoke(this, e);
+ }
+ }
+ }
+
+ protected virtual void OnClockAdjusted(EventArgs e)
+ {
+ if (!IsQuiescent)
+ {
+ ClockAdjusted?.Invoke(this, e);
+ }
+ else
+ {
+ // Retain the latest ClockAdjusted event until we unquiesce
+ Interlocked.Exchange(ref _pendingClockAdjustedEventArgs, e);
+ }
+ }
+
+ private void Context_ClockAdjusted(object? _, EventArgs __) => OnClockAdjusted(EventArgs.Empty);
+
+ private void Context_MetronomeTicked(object? _, EventArgs __) => OnMetronomeTicked(EventArgs.Empty);
+
+ private void Metronome_TimerCallback(object? _) => OnMetronomeTicked(EventArgs.Empty);
+
+
+ #region ISystemClock
+
+ ///
+ public DateTimeOffset UtcNow { get => _clock.UtcNow; }
+ ///
+ public long UtcNowClockOffset { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _clock.UtcNowClockOffset; }
+ ///
+ public long ClockOffsetUnitsPerMillisecond { [MethodImpl(MethodImplOptions.AggressiveInlining)] get => _clock.ClockOffsetUnitsPerMillisecond; }
+
+ ///
+ [MethodImpl(MethodImplOptions.AggressiveInlining)]
+ public DateTimeOffset ClockOffsetToUtcDateTimeOffset(long offset) => _clock.ClockOffsetToUtcDateTimeOffset(offset);
+ ///
+ public long DateTimeOffsetToClockOffset(DateTimeOffset offset) => _clock.DateTimeOffsetToClockOffset(offset);
+
+ #endregion
+
+ #region ISystemClockTemporalContext
+
+ ///
+ public TimeSpan? MetronomeIntervalTimeSpan { get => _metronomeIntervalTimeSpan; }
+ ///
+ public event EventHandler? ClockAdjusted;
+ ///
+ public event EventHandler? MetronomeTicked;
+
+ #endregion
+
+ #region IAsyncDisposable/IDisposable
+
+ ///
+ public void Dispose()
+ {
+ IsQuiescent = true;
+
+ // This method is re-entrant
+ DetachExternalTemporalContext();
+
+ Dispose(disposing: true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ IsQuiescent = true;
+
+ // This method is re-entrant
+ DetachExternalTemporalContext();
+
+ await DisposeAsyncCore().ConfigureAwait(false);
+
+ Dispose(disposing: false);
+ GC.SuppressFinalize(this);
+ }
+
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing)
+ {
+ // This method is re-entrant
+ DisposeInternalMetronome();
+ }
+ }
+
+ protected virtual async ValueTask DisposeAsyncCore()
+ {
+ // This method is re-entrant
+ await DisposeInternalMetronomeAsync().ConfigureAwait(false);
+ }
+
+ #endregion
+ }
+}
+
+#nullable restore