Private/Kinds/DiskTest.ps1

# The DiskTest Kind: what the system disk actually does, rather than what it claims.
#
# The Storage Kind reads what Windows says about the disk. This one writes to it. The two
# disagree often enough to be worth both: a drive that reports Healthy and idle can still
# take 40 ms to commit a 4 KB write once real-time scanning and encryption are in the way,
# and that number is what a Technician needs when Outlook feels slow on a healthy machine.

# The scratch file is written in 4 MB blocks, and the latency probe writes 4 KB at a time
# because that is the shape of the writes Outlook and a database client actually make.
$script:DiskTestBlockBytes       = 4MB
$script:DiskTestSmallWriteBytes  = 4096
$script:DiskTestSmallWriteCount  = 500

# A test that fills the disk it is measuring is worse than no test, so it wants room for
# the file three times over before it starts.
$script:DiskTestFreeSpaceFactor  = 3

function Get-DiskTestData {
    <#
    .SYNOPSIS
        Measures the system disk by writing to it. Decides nothing about what it measures.
    .DESCRIPTION
        The scratch file is removed in a finally block, so an interrupted Run does not
        leave a gigabyte behind on a Customer's machine. Whether there was room to run at
        all is reported as data - the Judge decides what an unrun measurement means.
    #>

    [CmdletBinding()]
    [OutputType([psobject])]
    param([hashtable]$Parameters = @{})

    $sizeMB = [int](Get-Parameter $Parameters 'SizeMB' 1024)
    $path   = Get-Parameter $Parameters 'Path' $env:TEMP

    $required = [long]$sizeMB * 1MB * $script:DiskTestFreeSpaceFactor
    $free     = $null
    $qualifier = Split-Path $path -Qualifier -ErrorAction SilentlyContinue
    if ($qualifier) {
        $volume = Get-CimInstance Win32_LogicalDisk -Filter "DeviceID='$qualifier'" -ErrorAction SilentlyContinue
        $free   = $volume.FreeSpace
    }

    $data = [ordered]@{
        PSTypeName          = 'Gutcheck.Data.DiskTest'
        Path                = $path
        SizeMB              = $sizeMB
        FreeBytes           = $free
        RequiredBytes       = $required
        Measured            = $false
        SequentialWriteMBps = $null
        SequentialReadMBps  = $null
        SyncWriteLatencyMs  = @()
    }

    if ($null -eq $free -or $free -lt $required) { return [pscustomobject]$data }

    $file   = Join-Path $path ('gutcheck_disktest_{0}.tmp' -f [guid]::NewGuid())
    $blocks = [int]($sizeMB * 1MB / $script:DiskTestBlockBytes)
    if ($blocks -lt 1) { $blocks = 1 }

    $buffer = New-Object byte[] $script:DiskTestBlockBytes
    (New-Object Random).NextBytes($buffer)

    $stream = $null
    try {
        # WriteThrough, so the measurement is of the disk rather than of the write cache.
        $stream = New-Object IO.FileStream($file, [IO.FileMode]::Create, [IO.FileAccess]::ReadWrite,
                                           [IO.FileShare]::None, 4096, [IO.FileOptions]::WriteThrough)

        $watch = [Diagnostics.Stopwatch]::StartNew()
        $writing = Get-Text 'Console.Progress.DiskWrite'
        $shownPercent = -1
        for ($i = 0; $i -lt $blocks; $i++) {
            # Drawn with the stopwatch paused, and only when the percentage moves, so the
            # throughput measured is the disk's and not the console's.
            $percent = [int][math]::Floor($i * 100 / $blocks)
            if ($percent -ne $shownPercent) {
                $watch.Stop()
                Write-CheckProgress -Status $writing -Step $i -Of $blocks
                $watch.Start()
                $shownPercent = $percent
            }
            $stream.Write($buffer, 0, $script:DiskTestBlockBytes)
        }
        $stream.Flush($true)
        $watch.Stop()
        if ($watch.Elapsed.TotalSeconds -gt 0) {
            $data.SequentialWriteMBps = ($blocks * $script:DiskTestBlockBytes / 1MB) / $watch.Elapsed.TotalSeconds
        }

        # Small synchronous writes at random offsets: what an OST or a database file does.
        $small = New-Object byte[] $script:DiskTestSmallWriteBytes
        (New-Object Random).NextBytes($small)
        $random    = New-Object Random
        $maxOffset = [int]([long]$blocks * $script:DiskTestBlockBytes / $script:DiskTestSmallWriteBytes) - 1
        if ($maxOffset -lt 1) { $maxOffset = 1 }

        $latencies = New-Object System.Collections.Generic.List[double]
        $latencyStatus = Get-Text 'Console.Progress.DiskLatency'
        for ($i = 0; $i -lt $script:DiskTestSmallWriteCount; $i++) {
            # Outside the stopwatch, which starts below, so the latency is the disk's alone.
            Write-CheckProgress -Status $latencyStatus -Step $i -Of $script:DiskTestSmallWriteCount
            $stream.Position = [long]$random.Next(0, $maxOffset) * $script:DiskTestSmallWriteBytes
            $one = [Diagnostics.Stopwatch]::StartNew()
            $stream.Write($small, 0, $script:DiskTestSmallWriteBytes)
            $stream.Flush($true)
            $latencies.Add($one.Elapsed.TotalMilliseconds)
        }
        $data.SyncWriteLatencyMs = @($latencies)

        $stream.Dispose(); $stream = $null

        $watch.Restart()
        $read = [IO.File]::OpenRead($file)
        try { while ($read.Read($buffer, 0, $script:DiskTestBlockBytes) -gt 0) { } }
        finally { $read.Dispose() }
        $watch.Stop()
        if ($watch.Elapsed.TotalSeconds -gt 0) {
            $data.SequentialReadMBps = ($blocks * $script:DiskTestBlockBytes / 1MB) / $watch.Elapsed.TotalSeconds
        }

        $data.Measured = $true
    }
    finally {
        if ($stream) { $stream.Dispose() }
        Remove-Item $file -Force -ErrorAction SilentlyContinue
    }

    [pscustomobject]$data
}

function ConvertTo-DiskTestFinding {
    [CmdletBinding()]
    [OutputType([psobject])]
    param(
        [AllowNull()]$Data,
        [hashtable]$Parameters = @{}
    )

    if (-not (Get-DataProperty $Data 'Measured')) {
        return New-DiskTestSkippedFinding -Data $Data
    }

    New-SequentialWriteFinding  -Data $Data -Parameters $Parameters
    New-SyncWriteLatencyFinding -Data $Data -Parameters $Parameters
    New-SequentialReadFinding   -Data $Data -Parameters $Parameters
}

function New-DiskTestSkippedFinding {
    <#
    .SYNOPSIS
        Why nothing was measured. A Check that did not run belongs in the Report.
    #>

    [CmdletBinding()]
    param([AllowNull()]$Data)

    $free     = ConvertTo-Number (Get-DataProperty $Data 'FreeBytes')
    $required = ConvertTo-Number (Get-DataProperty $Data 'RequiredBytes')

    if ($null -eq $free) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.DiskTest') `
            -Hint (Get-Text 'Hint.DiskTest.TheScratchVolumeDidNot')
    }

    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.DiskTest') -Severity INFO `
        -Value ((Get-Text 'Value.DiskTest.NotEnoughSpace') -f ($free / 1GB), ($required / 1GB)) `
        -Hint (Get-Text 'Hint.DiskTest.FreeUpSpaceAndRerun')
}

function New-SequentialWriteFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    # Graded downwards: it is the small number that is the problem.
    $warnBelow = Get-Parameter $Parameters 'SequentialWriteWarnBelowMBps' 250
    $failBelow = Get-Parameter $Parameters 'SequentialWriteFailBelowMBps' 80

    $rate = ConvertTo-Number (Get-DataProperty $Data 'SequentialWriteMBps')
    if ($null -eq $rate) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') `
            -Hint (Get-Text 'Hint.DiskTest.TheWriteWasNotTimed')
    }

    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialWriteWriteThrough') `
        -Severity (Get-SeverityBelow $rate $warnBelow $failBelow) -Value ('{0:N0} MB/s' -f $rate) `
        -Hint (Get-Text 'Hint.DiskTest.SATASSD300500NVMe')
}

function New-SyncWriteLatencyFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $warn = Get-Parameter $Parameters 'SyncWriteLatencyWarnMs' 8
    $fail = Get-Parameter $Parameters 'SyncWriteLatencyFailMs' 20

    $samples = (Get-DataCollection $Data 'SyncWriteLatencyMs')
    $average = Get-SampleAverage $samples
    if ($null -eq $average) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') `
            -Hint (Get-Text 'Hint.DiskTest.NoSmallWriteLatencyWas')
    }

    # Judged on the average, reported with the tail: one stall in five hundred writes is
    # not a sluggish disk, but a Technician still wants to see that it happened.
    $p95     = Get-SamplePercentile $samples 95
    $worst   = Get-SamplePercentile $samples 100

    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.4KSyncWriteLatency') `
        -Severity (Get-Severity $average $warn $fail) `
        -Value ((Get-Text 'Value.DiskTest.Latency') -f $average, $p95, $worst) `
        -Hint (Get-Text 'Hint.DiskTest.HighLatencySluggishOutlookAnd')
}

function New-SequentialReadFinding {
    [CmdletBinding()]
    param([AllowNull()]$Data, [hashtable]$Parameters)

    $rate = ConvertTo-Number (Get-DataProperty $Data 'SequentialReadMBps')
    if ($null -eq $rate) {
        return New-UnavailableFinding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialRead') `
            -Hint (Get-Text 'Hint.DiskTest.TheReadWasNotTimed')
    }

    # Never graded: the file it reads back was just written, so the cache serves much of
    # it and the number says more about RAM than about the disk.
    New-Finding -Category Storage -Check (Get-Text 'Check.DiskTest.SequentialRead') -Severity INFO `
        -Value ('{0:N0} MB/s' -f $rate) -Hint (Get-Text 'Hint.DiskTest.MayBeInflatedByThe')
}