perf(headless): establish K4 resource telemetry
This commit is contained in:
parent
827a039760
commit
cb512fd091
5 changed files with 662 additions and 4 deletions
|
|
@ -1,5 +1,6 @@
|
||||||
using System.Diagnostics;
|
using System.Diagnostics;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using AcDream.Headless.Hosting;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
|
|
||||||
namespace AcDream.Headless.Diagnostics;
|
namespace AcDream.Headless.Diagnostics;
|
||||||
|
|
@ -72,6 +73,47 @@ internal sealed class HeadlessDiagnosticWriter
|
||||||
generation,
|
generation,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
internal void Resources(
|
||||||
|
string state,
|
||||||
|
in HeadlessProcessResourceSnapshot snapshot)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(state);
|
||||||
|
HeadlessProcessContentSnapshot? content = snapshot.Content;
|
||||||
|
Write(new
|
||||||
|
{
|
||||||
|
kind = "resources",
|
||||||
|
state,
|
||||||
|
sequence = snapshot.Sequence,
|
||||||
|
monotonicTimestamp = snapshot.MonotonicTimestamp,
|
||||||
|
process = snapshot.Process,
|
||||||
|
managed = snapshot.Managed,
|
||||||
|
sessions = snapshot.Sessions,
|
||||||
|
scheduler = new
|
||||||
|
{
|
||||||
|
snapshot.Scheduler.SessionCount,
|
||||||
|
snapshot.Scheduler.WaitCount,
|
||||||
|
snapshot.Scheduler.TurnCount,
|
||||||
|
snapshot.Scheduler.CatchUpCollapseCount,
|
||||||
|
snapshot.Scheduler.LateDeadlineCount,
|
||||||
|
snapshot.Scheduler.MeanLatenessMilliseconds,
|
||||||
|
snapshot.Scheduler.MaximumLatenessMilliseconds,
|
||||||
|
snapshot.Scheduler.ActiveSessionCount,
|
||||||
|
snapshot.Scheduler.FaultedSessionCount,
|
||||||
|
snapshot.Scheduler.NextDeadline,
|
||||||
|
},
|
||||||
|
content = content is null
|
||||||
|
? null
|
||||||
|
: new
|
||||||
|
{
|
||||||
|
content.Value.LeaseCount,
|
||||||
|
content.Value.IsDisposeRequested,
|
||||||
|
content.Value.IsDisposed,
|
||||||
|
content.Value.MappedVirtualBytes,
|
||||||
|
content.Value.IsConverged,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private void Write<T>(T value)
|
private void Write<T>(T value)
|
||||||
{
|
{
|
||||||
_output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
|
_output.WriteLine(JsonSerializer.Serialize(value, JsonOptions));
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,270 @@
|
||||||
|
using System.Diagnostics;
|
||||||
|
using AcDream.Headless.Hosting;
|
||||||
|
using AcDream.Runtime;
|
||||||
|
|
||||||
|
namespace AcDream.Headless.Diagnostics;
|
||||||
|
|
||||||
|
internal readonly record struct HeadlessProcessUsageSnapshot(
|
||||||
|
long WorkingSetBytes,
|
||||||
|
long PrivateBytes,
|
||||||
|
long VirtualBytes,
|
||||||
|
long TotalProcessorTimeTicks,
|
||||||
|
double SampleIntervalMilliseconds,
|
||||||
|
double CpuCorePercent,
|
||||||
|
double CpuMachinePercent,
|
||||||
|
int ProcessorCount,
|
||||||
|
int ThreadCount,
|
||||||
|
int HandleCount,
|
||||||
|
int FileDescriptorCount,
|
||||||
|
int SocketDescriptorCount);
|
||||||
|
|
||||||
|
internal readonly record struct HeadlessManagedUsageSnapshot(
|
||||||
|
long LiveBytes,
|
||||||
|
long HeapSizeBytes,
|
||||||
|
long FragmentedBytes,
|
||||||
|
long CommittedBytes,
|
||||||
|
long TotalAllocatedBytes,
|
||||||
|
long MemoryLoadBytes,
|
||||||
|
long HighMemoryLoadThresholdBytes,
|
||||||
|
double PauseTimePercentage,
|
||||||
|
int Gen0Collections,
|
||||||
|
int Gen1Collections,
|
||||||
|
int Gen2Collections);
|
||||||
|
|
||||||
|
internal readonly record struct HeadlessSessionUsageSnapshot(
|
||||||
|
int ConfiguredCount,
|
||||||
|
int InWorldCount,
|
||||||
|
int FaultedCount,
|
||||||
|
int PendingReconnectCount,
|
||||||
|
int ConvergedRuntimeCount,
|
||||||
|
int EntityCount,
|
||||||
|
int InventoryObjectCount,
|
||||||
|
int HostLeaseCount);
|
||||||
|
|
||||||
|
internal readonly record struct HeadlessProcessResourceSnapshot(
|
||||||
|
long Sequence,
|
||||||
|
long MonotonicTimestamp,
|
||||||
|
HeadlessProcessUsageSnapshot Process,
|
||||||
|
HeadlessManagedUsageSnapshot Managed,
|
||||||
|
HeadlessSessionUsageSnapshot Sessions,
|
||||||
|
HeadlessSchedulerSnapshot Scheduler,
|
||||||
|
HeadlessProcessContentSnapshot? Content);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Captures one low-frequency process-wide resource sample. The sampler owns
|
||||||
|
/// no timer and no worker; the absolute-deadline process scheduler invokes it.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class HeadlessProcessResourceSampler : IDisposable
|
||||||
|
{
|
||||||
|
private readonly TimeProvider _timeProvider;
|
||||||
|
private readonly Process _process;
|
||||||
|
private long _sequence;
|
||||||
|
private long _previousTimestamp;
|
||||||
|
private long _previousProcessorTicks;
|
||||||
|
private bool _hasPreviousSample;
|
||||||
|
private bool _disposed;
|
||||||
|
|
||||||
|
internal HeadlessProcessResourceSampler(
|
||||||
|
TimeProvider? timeProvider = null,
|
||||||
|
Process? process = null)
|
||||||
|
{
|
||||||
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||||
|
_process = process ?? Process.GetCurrentProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal HeadlessProcessResourceSnapshot Capture(
|
||||||
|
IReadOnlyList<HeadlessSessionHost> sessions,
|
||||||
|
HeadlessSchedulerSnapshot scheduler,
|
||||||
|
HeadlessProcessContentSnapshot? content)
|
||||||
|
{
|
||||||
|
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||||
|
ArgumentNullException.ThrowIfNull(sessions);
|
||||||
|
|
||||||
|
long now = _timeProvider.GetTimestamp();
|
||||||
|
_process.Refresh();
|
||||||
|
long processorTicks = _process.TotalProcessorTime.Ticks;
|
||||||
|
double intervalMilliseconds = 0d;
|
||||||
|
double cpuCorePercent = 0d;
|
||||||
|
double cpuMachinePercent = 0d;
|
||||||
|
if (_hasPreviousSample && now > _previousTimestamp)
|
||||||
|
{
|
||||||
|
double intervalSeconds = _timeProvider
|
||||||
|
.GetElapsedTime(_previousTimestamp, now)
|
||||||
|
.TotalSeconds;
|
||||||
|
long processorDelta = Math.Max(
|
||||||
|
0L,
|
||||||
|
processorTicks - _previousProcessorTicks);
|
||||||
|
if (intervalSeconds > 0d)
|
||||||
|
{
|
||||||
|
intervalMilliseconds = intervalSeconds * 1000d;
|
||||||
|
cpuCorePercent =
|
||||||
|
processorDelta
|
||||||
|
* 100d
|
||||||
|
/ TimeSpan.TicksPerSecond
|
||||||
|
/ intervalSeconds;
|
||||||
|
cpuMachinePercent =
|
||||||
|
cpuCorePercent
|
||||||
|
/ Math.Max(1, Environment.ProcessorCount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_previousTimestamp = now;
|
||||||
|
_previousProcessorTicks = processorTicks;
|
||||||
|
_hasPreviousSample = true;
|
||||||
|
|
||||||
|
int inWorldCount = 0;
|
||||||
|
int faultedCount = 0;
|
||||||
|
int pendingReconnectCount = 0;
|
||||||
|
int convergedRuntimeCount = 0;
|
||||||
|
int entityCount = 0;
|
||||||
|
int inventoryObjectCount = 0;
|
||||||
|
int hostLeaseCount = 0;
|
||||||
|
for (int index = 0; index < sessions.Count; index++)
|
||||||
|
{
|
||||||
|
HeadlessSessionHost session = sessions[index];
|
||||||
|
RuntimeStateCheckpoint checkpoint =
|
||||||
|
session.Runtime.CaptureCheckpoint();
|
||||||
|
GameRuntimeOwnershipSnapshot ownership =
|
||||||
|
session.Runtime.CaptureOwnership();
|
||||||
|
if (session.Runtime.Session.IsInWorld)
|
||||||
|
inWorldCount++;
|
||||||
|
if (session.IsFaulted)
|
||||||
|
faultedCount++;
|
||||||
|
if (session.IsReconnectPending)
|
||||||
|
pendingReconnectCount++;
|
||||||
|
if (ownership.IsConverged)
|
||||||
|
convergedRuntimeCount++;
|
||||||
|
entityCount = checked(entityCount + checkpoint.EntityCount);
|
||||||
|
inventoryObjectCount = checked(
|
||||||
|
inventoryObjectCount
|
||||||
|
+ checkpoint.InventoryObjectCount);
|
||||||
|
hostLeaseCount = checked(
|
||||||
|
hostLeaseCount + ownership.HostLeaseCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
GCMemoryInfo memory = GC.GetGCMemoryInfo(GCKind.Any);
|
||||||
|
CaptureLinuxDescriptors(
|
||||||
|
out int fileDescriptorCount,
|
||||||
|
out int socketDescriptorCount);
|
||||||
|
var process = new HeadlessProcessUsageSnapshot(
|
||||||
|
_process.WorkingSet64,
|
||||||
|
_process.PrivateMemorySize64,
|
||||||
|
_process.VirtualMemorySize64,
|
||||||
|
processorTicks,
|
||||||
|
intervalMilliseconds,
|
||||||
|
cpuCorePercent,
|
||||||
|
cpuMachinePercent,
|
||||||
|
Environment.ProcessorCount,
|
||||||
|
TryGetThreadCount(_process),
|
||||||
|
TryGetHandleCount(_process),
|
||||||
|
fileDescriptorCount,
|
||||||
|
socketDescriptorCount);
|
||||||
|
var managed = new HeadlessManagedUsageSnapshot(
|
||||||
|
GC.GetTotalMemory(forceFullCollection: false),
|
||||||
|
memory.HeapSizeBytes,
|
||||||
|
memory.FragmentedBytes,
|
||||||
|
memory.TotalCommittedBytes,
|
||||||
|
GC.GetTotalAllocatedBytes(precise: false),
|
||||||
|
memory.MemoryLoadBytes,
|
||||||
|
memory.HighMemoryLoadThresholdBytes,
|
||||||
|
memory.PauseTimePercentage,
|
||||||
|
GC.CollectionCount(0),
|
||||||
|
GC.CollectionCount(1),
|
||||||
|
GC.CollectionCount(2));
|
||||||
|
var sessionUsage = new HeadlessSessionUsageSnapshot(
|
||||||
|
sessions.Count,
|
||||||
|
inWorldCount,
|
||||||
|
faultedCount,
|
||||||
|
pendingReconnectCount,
|
||||||
|
convergedRuntimeCount,
|
||||||
|
entityCount,
|
||||||
|
inventoryObjectCount,
|
||||||
|
hostLeaseCount);
|
||||||
|
return new HeadlessProcessResourceSnapshot(
|
||||||
|
checked(++_sequence),
|
||||||
|
now,
|
||||||
|
process,
|
||||||
|
managed,
|
||||||
|
sessionUsage,
|
||||||
|
scheduler,
|
||||||
|
content);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (_disposed)
|
||||||
|
return;
|
||||||
|
_process.Dispose();
|
||||||
|
_disposed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TryGetThreadCount(Process process)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return process.Threads.Count;
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int TryGetHandleCount(Process process)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return process.HandleCount;
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CaptureLinuxDescriptors(
|
||||||
|
out int fileDescriptorCount,
|
||||||
|
out int socketDescriptorCount)
|
||||||
|
{
|
||||||
|
fileDescriptorCount = -1;
|
||||||
|
socketDescriptorCount = -1;
|
||||||
|
if (!OperatingSystem.IsLinux())
|
||||||
|
return;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
int descriptors = 0;
|
||||||
|
int sockets = 0;
|
||||||
|
foreach (string path
|
||||||
|
in Directory.EnumerateFileSystemEntries("/proc/self/fd"))
|
||||||
|
{
|
||||||
|
descriptors++;
|
||||||
|
string? target = new FileInfo(path).LinkTarget;
|
||||||
|
if (target?.StartsWith(
|
||||||
|
"socket:[",
|
||||||
|
StringComparison.Ordinal) == true)
|
||||||
|
{
|
||||||
|
sockets++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fileDescriptorCount = descriptors;
|
||||||
|
socketDescriptorCount = sockets;
|
||||||
|
}
|
||||||
|
catch (IOException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (UnauthorizedAccessException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
catch (PlatformNotSupportedException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -13,6 +13,7 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
private readonly HeadlessProcessScheduler _scheduler;
|
private readonly HeadlessProcessScheduler _scheduler;
|
||||||
private readonly HeadlessDiagnosticWriter _diagnostics;
|
private readonly HeadlessDiagnosticWriter _diagnostics;
|
||||||
private readonly HeadlessProcessContentOwner? _content;
|
private readonly HeadlessProcessContentOwner? _content;
|
||||||
|
private readonly HeadlessProcessResourceSampler _resources;
|
||||||
private int _disposeIndex;
|
private int _disposeIndex;
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
|
|
@ -50,6 +51,7 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
var sessions = new List<HeadlessSessionHost>(
|
var sessions = new List<HeadlessSessionHost>(
|
||||||
configuration.Sessions.Count);
|
configuration.Sessions.Count);
|
||||||
HeadlessProcessContentOwner? content = null;
|
HeadlessProcessContentOwner? content = null;
|
||||||
|
HeadlessProcessResourceSampler? resources = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
if (configuration.Process?.Content is { } contentDescriptor)
|
if (configuration.Process?.Content is { } contentDescriptor)
|
||||||
|
|
@ -102,14 +104,23 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
}
|
}
|
||||||
|
|
||||||
_sessions = sessions.ToArray();
|
_sessions = sessions.ToArray();
|
||||||
|
resources = new HeadlessProcessResourceSampler(timeProvider);
|
||||||
_scheduler = new HeadlessProcessScheduler(
|
_scheduler = new HeadlessProcessScheduler(
|
||||||
_sessions,
|
_sessions,
|
||||||
timeProvider);
|
timeProvider,
|
||||||
|
observation: snapshot =>
|
||||||
|
CaptureResources(
|
||||||
|
"periodic",
|
||||||
|
resources,
|
||||||
|
snapshot,
|
||||||
|
content));
|
||||||
|
_resources = resources;
|
||||||
_content = content;
|
_content = content;
|
||||||
_disposeIndex = _sessions.Length - 1;
|
_disposeIndex = _sessions.Length - 1;
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
|
resources?.Dispose();
|
||||||
for (int index = sessions.Count - 1; index >= 0; index--)
|
for (int index = sessions.Count - 1; index >= 0; index--)
|
||||||
sessions[index].Dispose();
|
sessions[index].Dispose();
|
||||||
content?.Dispose();
|
content?.Dispose();
|
||||||
|
|
@ -164,6 +175,12 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
"running",
|
"running",
|
||||||
session.Runtime);
|
session.Runtime);
|
||||||
}
|
}
|
||||||
|
_scheduler.RebaseDeadlinesAfterSessionStart();
|
||||||
|
CaptureResources(
|
||||||
|
"running-start",
|
||||||
|
_resources,
|
||||||
|
_scheduler.CaptureSnapshot(),
|
||||||
|
_content);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
|
@ -180,6 +197,11 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
return HeadlessExitCode.RuntimeError;
|
return HeadlessExitCode.RuntimeError;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
CaptureResources(
|
||||||
|
"running-stop",
|
||||||
|
_resources,
|
||||||
|
_scheduler.CaptureSnapshot(),
|
||||||
|
_content);
|
||||||
return _scheduler.CaptureSnapshot().FaultedSessionCount > 0
|
return _scheduler.CaptureSnapshot().FaultedSessionCount > 0
|
||||||
? HeadlessExitCode.RuntimeError
|
? HeadlessExitCode.RuntimeError
|
||||||
: HeadlessExitCode.Success;
|
: HeadlessExitCode.Success;
|
||||||
|
|
@ -195,6 +217,25 @@ internal sealed class HeadlessProcessHost : IDisposable
|
||||||
_disposeIndex--;
|
_disposeIndex--;
|
||||||
}
|
}
|
||||||
_content?.Dispose();
|
_content?.Dispose();
|
||||||
|
CaptureResources(
|
||||||
|
"disposed",
|
||||||
|
_resources,
|
||||||
|
_scheduler.CaptureSnapshot(),
|
||||||
|
_content);
|
||||||
|
_resources.Dispose();
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CaptureResources(
|
||||||
|
string state,
|
||||||
|
HeadlessProcessResourceSampler resources,
|
||||||
|
HeadlessSchedulerSnapshot scheduler,
|
||||||
|
HeadlessProcessContentOwner? content)
|
||||||
|
{
|
||||||
|
HeadlessProcessResourceSnapshot snapshot = resources.Capture(
|
||||||
|
_sessions,
|
||||||
|
scheduler,
|
||||||
|
content?.CaptureSnapshot());
|
||||||
|
_diagnostics.Resources(state, snapshot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,9 @@ namespace AcDream.Headless.Hosting;
|
||||||
|
|
||||||
internal sealed class HeadlessProcessScheduler
|
internal sealed class HeadlessProcessScheduler
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan MinimumTimerDelay =
|
||||||
|
TimeSpan.FromMilliseconds(1);
|
||||||
|
|
||||||
private sealed class Slot
|
private sealed class Slot
|
||||||
{
|
{
|
||||||
internal Slot(
|
internal Slot(
|
||||||
|
|
@ -25,15 +28,23 @@ internal sealed class HeadlessProcessScheduler
|
||||||
private readonly long _periodTicks;
|
private readonly long _periodTicks;
|
||||||
private readonly int _maximumCatchUpTurns;
|
private readonly int _maximumCatchUpTurns;
|
||||||
private readonly Slot[] _slots;
|
private readonly Slot[] _slots;
|
||||||
|
private readonly Action<HeadlessSchedulerSnapshot>? _observation;
|
||||||
|
private readonly long _observationPeriodTicks;
|
||||||
|
private long _nextObservationDeadline;
|
||||||
private long _waitCount;
|
private long _waitCount;
|
||||||
private long _turnCount;
|
private long _turnCount;
|
||||||
private long _catchUpCollapseCount;
|
private long _catchUpCollapseCount;
|
||||||
|
private long _lateDeadlineCount;
|
||||||
|
private long _totalLatenessTicks;
|
||||||
|
private long _maximumLatenessTicks;
|
||||||
|
|
||||||
internal HeadlessProcessScheduler(
|
internal HeadlessProcessScheduler(
|
||||||
IReadOnlyList<HeadlessSessionHost> sessions,
|
IReadOnlyList<HeadlessSessionHost> sessions,
|
||||||
TimeProvider? timeProvider = null,
|
TimeProvider? timeProvider = null,
|
||||||
TimeSpan? turnPeriod = null,
|
TimeSpan? turnPeriod = null,
|
||||||
int maximumCatchUpTurns = 8)
|
int maximumCatchUpTurns = 8,
|
||||||
|
Action<HeadlessSchedulerSnapshot>? observation = null,
|
||||||
|
TimeSpan? observationPeriod = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(sessions);
|
ArgumentNullException.ThrowIfNull(sessions);
|
||||||
if (sessions.Count == 0)
|
if (sessions.Count == 0)
|
||||||
|
|
@ -57,8 +68,22 @@ internal sealed class HeadlessProcessScheduler
|
||||||
_timeProvider,
|
_timeProvider,
|
||||||
configuredTurnPeriod);
|
configuredTurnPeriod);
|
||||||
_maximumCatchUpTurns = maximumCatchUpTurns;
|
_maximumCatchUpTurns = maximumCatchUpTurns;
|
||||||
|
_observation = observation;
|
||||||
|
TimeSpan configuredObservationPeriod =
|
||||||
|
observationPeriod ?? TimeSpan.FromSeconds(30);
|
||||||
|
if (configuredObservationPeriod <= TimeSpan.Zero)
|
||||||
|
{
|
||||||
|
throw new ArgumentOutOfRangeException(
|
||||||
|
nameof(observationPeriod));
|
||||||
|
}
|
||||||
|
_observationPeriodTicks = DurationToTimestampTicks(
|
||||||
|
_timeProvider,
|
||||||
|
configuredObservationPeriod);
|
||||||
|
|
||||||
long start = _timeProvider.GetTimestamp();
|
long start = _timeProvider.GetTimestamp();
|
||||||
|
_nextObservationDeadline = AddTicks(
|
||||||
|
start,
|
||||||
|
_observationPeriodTicks);
|
||||||
_slots = new Slot[sessions.Count];
|
_slots = new Slot[sessions.Count];
|
||||||
for (int index = 0; index < sessions.Count; index++)
|
for (int index = 0; index < sessions.Count; index++)
|
||||||
{
|
{
|
||||||
|
|
@ -89,24 +114,60 @@ internal sealed class HeadlessProcessScheduler
|
||||||
Interlocked.Read(ref _waitCount),
|
Interlocked.Read(ref _waitCount),
|
||||||
Interlocked.Read(ref _turnCount),
|
Interlocked.Read(ref _turnCount),
|
||||||
Interlocked.Read(ref _catchUpCollapseCount),
|
Interlocked.Read(ref _catchUpCollapseCount),
|
||||||
|
Interlocked.Read(ref _lateDeadlineCount),
|
||||||
|
Interlocked.Read(ref _totalLatenessTicks),
|
||||||
|
Interlocked.Read(ref _maximumLatenessTicks),
|
||||||
|
_timeProvider.TimestampFrequency,
|
||||||
activeSessionCount,
|
activeSessionCount,
|
||||||
faultedSessionCount,
|
faultedSessionCount,
|
||||||
NextDeadline());
|
NextDeadline());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal void RebaseDeadlinesAfterSessionStart()
|
||||||
|
{
|
||||||
|
if (Interlocked.Read(ref _waitCount) != 0L
|
||||||
|
|| Interlocked.Read(ref _turnCount) != 0L
|
||||||
|
|| Interlocked.Read(ref _catchUpCollapseCount) != 0L
|
||||||
|
|| Interlocked.Read(ref _lateDeadlineCount) != 0L)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
"A running headless scheduler cannot rebase its deadlines.");
|
||||||
|
}
|
||||||
|
|
||||||
|
long start = _timeProvider.GetTimestamp();
|
||||||
|
for (int index = 0; index < _slots.Length; index++)
|
||||||
|
{
|
||||||
|
_slots[index].LastTurnTimestamp = start;
|
||||||
|
_slots[index].NextDeadline = AddTicks(
|
||||||
|
start,
|
||||||
|
_periodTicks);
|
||||||
|
}
|
||||||
|
_nextObservationDeadline = AddTicks(
|
||||||
|
start,
|
||||||
|
_observationPeriodTicks);
|
||||||
|
}
|
||||||
|
|
||||||
internal async Task RunAsync(CancellationToken cancellationToken)
|
internal async Task RunAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
while (!cancellationToken.IsCancellationRequested
|
while (!cancellationToken.IsCancellationRequested
|
||||||
&& HasActiveSession())
|
&& HasActiveSession())
|
||||||
{
|
{
|
||||||
long now = _timeProvider.GetTimestamp();
|
long now = _timeProvider.GetTimestamp();
|
||||||
if (DispatchDue(now))
|
bool dispatched = DispatchDue(now);
|
||||||
|
bool observed = DispatchObservationDue(now);
|
||||||
|
if (dispatched || observed)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
long deadline = NextDeadline();
|
long deadline = NextDeadline();
|
||||||
|
if (_observation is not null
|
||||||
|
&& _nextObservationDeadline < deadline)
|
||||||
|
{
|
||||||
|
deadline = _nextObservationDeadline;
|
||||||
|
}
|
||||||
TimeSpan delay = deadline <= now
|
TimeSpan delay = deadline <= now
|
||||||
? TimeSpan.Zero
|
? TimeSpan.Zero
|
||||||
: _timeProvider.GetElapsedTime(now, deadline);
|
: _timeProvider.GetElapsedTime(now, deadline);
|
||||||
|
delay = NormalizeTimerDelay(delay);
|
||||||
Interlocked.Increment(ref _waitCount);
|
Interlocked.Increment(ref _waitCount);
|
||||||
await Task.Delay(
|
await Task.Delay(
|
||||||
delay,
|
delay,
|
||||||
|
|
@ -116,6 +177,12 @@ internal sealed class HeadlessProcessScheduler
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static TimeSpan NormalizeTimerDelay(TimeSpan delay) =>
|
||||||
|
delay > TimeSpan.Zero
|
||||||
|
&& delay < MinimumTimerDelay
|
||||||
|
? MinimumTimerDelay
|
||||||
|
: delay;
|
||||||
|
|
||||||
internal bool DispatchDue(long nowTimestamp)
|
internal bool DispatchDue(long nowTimestamp)
|
||||||
{
|
{
|
||||||
bool dispatched = false;
|
bool dispatched = false;
|
||||||
|
|
@ -132,6 +199,9 @@ internal sealed class HeadlessProcessScheduler
|
||||||
if (nowTimestamp < session.ReconnectDeadline)
|
if (nowTimestamp < session.ReconnectDeadline)
|
||||||
continue;
|
continue;
|
||||||
|
|
||||||
|
RecordLateness(
|
||||||
|
nowTimestamp,
|
||||||
|
session.ReconnectDeadline);
|
||||||
RuntimeSessionStartResult result =
|
RuntimeSessionStartResult result =
|
||||||
session.CompletePendingReconnect(nowTimestamp);
|
session.CompletePendingReconnect(nowTimestamp);
|
||||||
if (result.Status
|
if (result.Status
|
||||||
|
|
@ -164,8 +234,32 @@ internal sealed class HeadlessProcessScheduler
|
||||||
return dispatched;
|
return dispatched;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal bool DispatchObservationDue(long nowTimestamp)
|
||||||
|
{
|
||||||
|
if (_observation is null
|
||||||
|
|| nowTimestamp < _nextObservationDeadline)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
long dueCount =
|
||||||
|
1L
|
||||||
|
+ ((nowTimestamp - _nextObservationDeadline)
|
||||||
|
/ _observationPeriodTicks);
|
||||||
|
long advance = dueCount
|
||||||
|
> long.MaxValue / _observationPeriodTicks
|
||||||
|
? long.MaxValue
|
||||||
|
: dueCount * _observationPeriodTicks;
|
||||||
|
_nextObservationDeadline = AddTicks(
|
||||||
|
_nextObservationDeadline,
|
||||||
|
advance);
|
||||||
|
_observation(CaptureSnapshot());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private void DispatchSessionDue(Slot slot, long nowTimestamp)
|
private void DispatchSessionDue(Slot slot, long nowTimestamp)
|
||||||
{
|
{
|
||||||
|
RecordLateness(nowTimestamp, slot.NextDeadline);
|
||||||
long dueCount =
|
long dueCount =
|
||||||
1L + ((nowTimestamp - slot.NextDeadline) / _periodTicks);
|
1L + ((nowTimestamp - slot.NextDeadline) / _periodTicks);
|
||||||
int turns = (int)Math.Min(
|
int turns = (int)Math.Min(
|
||||||
|
|
@ -203,6 +297,27 @@ internal sealed class HeadlessProcessScheduler
|
||||||
Interlocked.Increment(ref _catchUpCollapseCount);
|
Interlocked.Increment(ref _catchUpCollapseCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void RecordLateness(long nowTimestamp, long deadline)
|
||||||
|
{
|
||||||
|
if (nowTimestamp <= deadline)
|
||||||
|
return;
|
||||||
|
|
||||||
|
long lateness = nowTimestamp - deadline;
|
||||||
|
Interlocked.Increment(ref _lateDeadlineCount);
|
||||||
|
Interlocked.Add(ref _totalLatenessTicks, lateness);
|
||||||
|
long maximum = Interlocked.Read(ref _maximumLatenessTicks);
|
||||||
|
while (lateness > maximum)
|
||||||
|
{
|
||||||
|
long observed = Interlocked.CompareExchange(
|
||||||
|
ref _maximumLatenessTicks,
|
||||||
|
lateness,
|
||||||
|
maximum);
|
||||||
|
if (observed == maximum)
|
||||||
|
break;
|
||||||
|
maximum = observed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private long NextDeadline()
|
private long NextDeadline()
|
||||||
{
|
{
|
||||||
long next = long.MaxValue;
|
long next = long.MaxValue;
|
||||||
|
|
@ -257,6 +372,25 @@ internal readonly record struct HeadlessSchedulerSnapshot(
|
||||||
long WaitCount,
|
long WaitCount,
|
||||||
long TurnCount,
|
long TurnCount,
|
||||||
long CatchUpCollapseCount,
|
long CatchUpCollapseCount,
|
||||||
|
long LateDeadlineCount,
|
||||||
|
long TotalLatenessTicks,
|
||||||
|
long MaximumLatenessTicks,
|
||||||
|
long TimestampFrequency,
|
||||||
int ActiveSessionCount,
|
int ActiveSessionCount,
|
||||||
int FaultedSessionCount,
|
int FaultedSessionCount,
|
||||||
long NextDeadline);
|
long NextDeadline)
|
||||||
|
{
|
||||||
|
internal double MeanLatenessMilliseconds =>
|
||||||
|
LateDeadlineCount == 0
|
||||||
|
? 0d
|
||||||
|
: TimestampTicksToMilliseconds(
|
||||||
|
TotalLatenessTicks / (double)LateDeadlineCount);
|
||||||
|
|
||||||
|
internal double MaximumLatenessMilliseconds =>
|
||||||
|
TimestampTicksToMilliseconds(MaximumLatenessTicks);
|
||||||
|
|
||||||
|
private double TimestampTicksToMilliseconds(double ticks) =>
|
||||||
|
TimestampFrequency <= 0L
|
||||||
|
? 0d
|
||||||
|
: ticks * 1000d / TimestampFrequency;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -77,6 +77,158 @@ public sealed class HeadlessProcessSchedulerTests
|
||||||
scheduler.CaptureSnapshot().CatchUpCollapseCount);
|
scheduler.CaptureSnapshot().CatchUpCollapseCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SchedulerMeasuresDeadlineLatenessWithoutAllocatingPerTurn()
|
||||||
|
{
|
||||||
|
var time = new ManualTimeProvider();
|
||||||
|
var operations = new FixtureSessionOperations();
|
||||||
|
using HeadlessSessionHost first =
|
||||||
|
CreateSession("late-first", time, operations);
|
||||||
|
using HeadlessSessionHost second =
|
||||||
|
CreateSession("late-second", time, operations);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
first.Start().Status);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
second.Start().Status);
|
||||||
|
var scheduler = new HeadlessProcessScheduler(
|
||||||
|
[first, second],
|
||||||
|
time);
|
||||||
|
|
||||||
|
time.Advance(TimeSpan.FromMilliseconds(20));
|
||||||
|
Assert.True(scheduler.DispatchDue(time.GetTimestamp()));
|
||||||
|
|
||||||
|
HeadlessSchedulerSnapshot snapshot =
|
||||||
|
scheduler.CaptureSnapshot();
|
||||||
|
Assert.Equal(2L, snapshot.LateDeadlineCount);
|
||||||
|
Assert.Equal(5d, snapshot.MeanLatenessMilliseconds);
|
||||||
|
Assert.Equal(5d, snapshot.MaximumLatenessMilliseconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ProcessObservationUsesOneAbsoluteDeadlineAndCollapsesPauses()
|
||||||
|
{
|
||||||
|
var time = new ManualTimeProvider();
|
||||||
|
var operations = new FixtureSessionOperations();
|
||||||
|
using HeadlessSessionHost session =
|
||||||
|
CreateSession("observation", time, operations);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
session.Start().Status);
|
||||||
|
int observationCount = 0;
|
||||||
|
var scheduler = new HeadlessProcessScheduler(
|
||||||
|
[session],
|
||||||
|
time,
|
||||||
|
observation: _ => observationCount++,
|
||||||
|
observationPeriod: TimeSpan.FromSeconds(1));
|
||||||
|
|
||||||
|
time.Advance(TimeSpan.FromMilliseconds(999));
|
||||||
|
Assert.False(
|
||||||
|
scheduler.DispatchObservationDue(time.GetTimestamp()));
|
||||||
|
time.Advance(TimeSpan.FromMilliseconds(1));
|
||||||
|
Assert.True(
|
||||||
|
scheduler.DispatchObservationDue(time.GetTimestamp()));
|
||||||
|
Assert.False(
|
||||||
|
scheduler.DispatchObservationDue(time.GetTimestamp()));
|
||||||
|
time.Advance(TimeSpan.FromMilliseconds(2500));
|
||||||
|
Assert.True(
|
||||||
|
scheduler.DispatchObservationDue(time.GetTimestamp()));
|
||||||
|
Assert.False(
|
||||||
|
scheduler.DispatchObservationDue(time.GetTimestamp()));
|
||||||
|
Assert.Equal(2, observationCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SessionStartupRebaseExcludesSequentialConnectTime()
|
||||||
|
{
|
||||||
|
var time = new ManualTimeProvider();
|
||||||
|
var operations = new FixtureSessionOperations();
|
||||||
|
using HeadlessSessionHost first =
|
||||||
|
CreateSession("rebase-first", time, operations);
|
||||||
|
using HeadlessSessionHost second =
|
||||||
|
CreateSession("rebase-second", time, operations);
|
||||||
|
var scheduler = new HeadlessProcessScheduler(
|
||||||
|
[first, second],
|
||||||
|
time);
|
||||||
|
|
||||||
|
time.Advance(TimeSpan.FromSeconds(4));
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
first.Start().Status);
|
||||||
|
time.Advance(TimeSpan.FromSeconds(6));
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
second.Start().Status);
|
||||||
|
scheduler.RebaseDeadlinesAfterSessionStart();
|
||||||
|
|
||||||
|
Assert.False(scheduler.DispatchDue(time.GetTimestamp()));
|
||||||
|
time.Advance(TimeSpan.FromMilliseconds(15));
|
||||||
|
Assert.True(scheduler.DispatchDue(time.GetTimestamp()));
|
||||||
|
HeadlessSchedulerSnapshot snapshot =
|
||||||
|
scheduler.CaptureSnapshot();
|
||||||
|
Assert.Equal(2L, snapshot.TurnCount);
|
||||||
|
Assert.Equal(0L, snapshot.LateDeadlineCount);
|
||||||
|
Assert.Equal(0L, snapshot.CatchUpCollapseCount);
|
||||||
|
Assert.Throws<InvalidOperationException>(
|
||||||
|
scheduler.RebaseDeadlinesAfterSessionStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData(0, 0)]
|
||||||
|
[InlineData(1, TimeSpan.TicksPerMillisecond)]
|
||||||
|
[InlineData(
|
||||||
|
TimeSpan.TicksPerMillisecond - 1,
|
||||||
|
TimeSpan.TicksPerMillisecond)]
|
||||||
|
[InlineData(
|
||||||
|
TimeSpan.TicksPerMillisecond,
|
||||||
|
TimeSpan.TicksPerMillisecond)]
|
||||||
|
[InlineData(
|
||||||
|
TimeSpan.TicksPerMillisecond + 1,
|
||||||
|
TimeSpan.TicksPerMillisecond + 1)]
|
||||||
|
public void SchedulerNeverBusyLoopsOnSubMillisecondTimerDelay(
|
||||||
|
long sourceTicks,
|
||||||
|
long expectedTicks)
|
||||||
|
{
|
||||||
|
TimeSpan normalized =
|
||||||
|
HeadlessProcessScheduler.NormalizeTimerDelay(
|
||||||
|
TimeSpan.FromTicks(sourceTicks));
|
||||||
|
|
||||||
|
Assert.Equal(expectedTicks, normalized.Ticks);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task SystemTimerCadenceDoesNotBusyLoopBetweenTurns()
|
||||||
|
{
|
||||||
|
var operations = new FixtureSessionOperations();
|
||||||
|
using HeadlessSessionHost session =
|
||||||
|
CreateSession(
|
||||||
|
"system-timer",
|
||||||
|
TimeProvider.System,
|
||||||
|
operations);
|
||||||
|
Assert.Equal(
|
||||||
|
RuntimeSessionStartStatus.Connected,
|
||||||
|
session.Start().Status);
|
||||||
|
var scheduler = new HeadlessProcessScheduler([session]);
|
||||||
|
scheduler.RebaseDeadlinesAfterSessionStart();
|
||||||
|
using var cancellation =
|
||||||
|
new CancellationTokenSource(TimeSpan.FromMilliseconds(250));
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await scheduler.RunAsync(cancellation.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
when (cancellation.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
HeadlessSchedulerSnapshot snapshot =
|
||||||
|
scheduler.CaptureSnapshot();
|
||||||
|
Assert.InRange(snapshot.WaitCount, 1L, 1000L);
|
||||||
|
Assert.True(snapshot.TurnCount > 0L);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ReconnectQuiescenceIsAMonotonicDeadlineNotABlockingWait()
|
public void ReconnectQuiescenceIsAMonotonicDeadlineNotABlockingWait()
|
||||||
{
|
{
|
||||||
|
|
@ -185,6 +337,25 @@ public sealed class HeadlessProcessSchedulerTests
|
||||||
"password",
|
"password",
|
||||||
diagnostics.ToString(),
|
diagnostics.ToString(),
|
||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"\"kind\":\"resources\"",
|
||||||
|
diagnostics.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"\"configuredCount\":2",
|
||||||
|
diagnostics.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
|
||||||
|
host.Dispose();
|
||||||
|
|
||||||
|
Assert.Contains(
|
||||||
|
"\"state\":\"disposed\"",
|
||||||
|
diagnostics.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
|
Assert.Contains(
|
||||||
|
"\"convergedRuntimeCount\":2",
|
||||||
|
diagnostics.ToString(),
|
||||||
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue