Private/Get-NSPScanBridgeDeliveryLifecycle.ps1

function Get-NSPScanBridgeDeliveryLifecycle {
    <#
    .SYNOPSIS
        Groups delivery-probe observations into one lifecycle per delivered file, following renames.
    .DESCRIPTION
        Pure: works on observation objects only. A watcher rename moves the lifecycle to its new
        name. Without a watcher, a temporary file that vanishes just before a PDF of the same length
        appears is merged as an inferred rename. A file recreated or shrinking at the same name
        starts a new lifecycle marked as a collision. Returns the lifecycles and, for each input
        observation, the lifecycle it belongs to (or $null).
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Observation,
        [Parameter(Mandatory)][double]$PollMilliseconds,
        [Parameter(Mandatory)][double]$QuietSeconds,
        [Parameter(Mandatory)][double]$EndMs
    )
    $all=New-Object System.Collections.Generic.List[object]
    $current=@{}; $closed=@{}
    $owners=New-Object 'object[]' $Observation.Count
    function New-Lifecycle($Path,$Ms) {
        $lifecycle=[pscustomobject]@{Key=$null;Path=$Path;Names=(New-Object System.Collections.Generic.List[string]);Samples=(New-Object System.Collections.Generic.List[object]);FirstMs=$Ms;EndStatus='Present';EndMs=$null;Rename=$null;Collision=$false}
        $lifecycle.Names.Add([IO.Path]::GetFileName($Path))
        $all.Add($lifecycle); $current[$Path.ToLowerInvariant()]=$lifecycle
        $lifecycle
    }
    for ($i=0; $i -lt $Observation.Count; $i++) {
        $o=$Observation[$i]; $path=[string]$o.Path; $key=$path.ToLowerInvariant()
        if ($o.Status -eq 'RenamedAway') { if ($current.ContainsKey($key)) { $owners[$i]=$current[$key] }; continue }
        if ($o.Source -eq 'FileSystemWatcher.Renamed' -and $o.OldPath) {
            $oldKey=([string]$o.OldPath).ToLowerInvariant()
            $moved=if($current.ContainsKey($oldKey)){$current[$oldKey]}elseif($closed.ContainsKey($oldKey) -and $closed[$oldKey].EndStatus -eq 'Missing'){$closed[$oldKey]}else{$null}
            if ($moved -and -not $moved.Rename) {
                $current.Remove($oldKey); $closed.Remove($oldKey)
                # Polling may have found the new name before the rename notification arrived.
                $early=$current[$key]
                if ($early -and $early -ne $moved) {
                    foreach ($s in $early.Samples) { $moved.Samples.Add($s) }
                    [void]$all.Remove($early)
                    for ($j=0; $j -lt $i; $j++) { if ($owners[$j] -eq $early) { $owners[$j]=$moved } }
                }
                $moved.EndStatus='Present'; $moved.EndMs=$null; $moved.Path=$path; $moved.Names.Add([IO.Path]::GetFileName($path))
                $moved.Rename=[pscustomobject]@{Method='Watcher';ElapsedMs=$o.ElapsedMs;FromExtension=[IO.Path]::GetExtension([string]$o.OldPath);ToExtension=[IO.Path]::GetExtension($path)}
                $current[$key]=$moved
            }
        }
        if ($o.Status -eq 'Present') {
            $lifecycle=$current[$key]
            if ($lifecycle -and $lifecycle.Samples.Count) {
                $last=$lifecycle.Samples[$lifecycle.Samples.Count-1]
                if ($o.Length -lt $last.Length -or $o.CreationTimeUtc -ne $last.CreationTimeUtc) {
                    $lifecycle.EndStatus='Replaced'; $lifecycle.EndMs=$o.ElapsedMs
                    $lifecycle=New-Lifecycle $path $o.ElapsedMs; $lifecycle.Collision=$true
                }
            }
            if (-not $lifecycle) { $lifecycle=New-Lifecycle $path $o.ElapsedMs }
            $lifecycle.Samples.Add([pscustomobject]@{Ms=[double]$o.ElapsedMs;Utc=$o.ObservedUtc;Extension=[IO.Path]::GetExtension($path);Length=[long]$o.Length;LastWriteTimeUtc=$o.LastWriteTimeUtc;CreationTimeUtc=$o.CreationTimeUtc;Shared=$o.SharedProbeStatus;SharedError=$o.SharedProbeError;Exclusive=$o.ProbeStatus;ExclusiveError=$o.ProbeError;Structure=$o.StructureComplete})
            $owners[$i]=$lifecycle
        } elseif ($o.Status -in @('Missing','Deleted') -and $current.ContainsKey($key)) {
            $lifecycle=$current[$key]; $current.Remove($key)
            $lifecycle.EndStatus=$o.Status; $lifecycle.EndMs=$o.ElapsedMs; $closed[$key]=$lifecycle
            $owners[$i]=$lifecycle
        }
    }
    # Polling-only rename inference: a temporary name vanishes and a PDF of the same length appears.
    $window=2*$PollMilliseconds+1000
    foreach ($pdf in @($all | Where-Object { -not $_.Rename -and $_.Samples.Count -and $_.Samples[0].Extension -ieq '.pdf' })) {
        $first=$pdf.Samples[0]
        $temp=$all | Where-Object {
            $_ -ne $pdf -and -not $_.Rename -and $_.Samples.Count -and $_.EndStatus -in @('Missing','Deleted') -and
            $_.Samples[$_.Samples.Count-1].Extension -ine '.pdf' -and $_.Samples[$_.Samples.Count-1].Length -eq $first.Length -and
            ($first.Ms-$_.Samples[$_.Samples.Count-1].Ms) -ge -$PollMilliseconds -and ($first.Ms-$_.Samples[$_.Samples.Count-1].Ms) -le $window
        } | Select-Object -First 1
        if (-not $temp) { continue }
        $pdf.Samples.InsertRange(0,$temp.Samples); $pdf.Names.InsertRange(0,$temp.Names)
        $pdf.FirstMs=$temp.FirstMs
        $pdf.Rename=[pscustomobject]@{Method='Inferred';ElapsedMs=$first.Ms;FromExtension=$temp.Samples[0].Extension;ToExtension='.pdf'}
        [void]$all.Remove($temp)
        for ($i=0; $i -lt $owners.Count; $i++) { if ($owners[$i] -eq $temp) { $owners[$i]=$pdf } }
    }
    $ordinal=0
    foreach ($lifecycle in @($all | Sort-Object FirstMs)) {
        $ordinal++; $lifecycle.Key='F'+$ordinal
        $samples=@($lifecycle.Samples | Sort-Object Ms)
        $changes=New-Object System.Collections.Generic.List[double]
        if ($samples.Count) { $changes.Add($samples[0].Ms) }
        for ($j=1; $j -lt $samples.Count; $j++) {
            if ($samples[$j].Length -ne $samples[$j-1].Length -or $samples[$j].LastWriteTimeUtc -ne $samples[$j-1].LastWriteTimeUtc) { $changes.Add($samples[$j].Ms) }
        }
        $lastChange=if($changes.Count){$changes[$changes.Count-1]}else{$lifecycle.FirstMs}
        $pause=0.0; for ($j=1; $j -lt $changes.Count; $j++) { $pause=[math]::Max($pause,$changes[$j]-$changes[$j-1]) }
        $final=if($samples.Count){$samples[$samples.Count-1]}else{$null}
        $after=@($samples | Where-Object { $_.Ms -ge $lastChange -and $_.Exclusive -eq 'Available' })
        $complete=@($after | Where-Object { $_.Structure -eq $true } | Select-Object -First 1)
        $end=if($null -ne $lifecycle.EndMs){$lifecycle.EndMs}else{$EndMs}
        $outcome=if($lifecycle.EndStatus -in @('Missing','Deleted')){'Deleted'}
            elseif($lifecycle.EndStatus -eq 'Replaced'){'Replaced'}
            elseif(-not $final -or $final.Extension -ine '.pdf'){'TempNotRenamed'}
            elseif(($end-$lastChange) -lt $QuietSeconds*1000){'StillChanging'}
            elseif(-not $after.Count){'NeverAvailable'}
            elseif($after[$after.Count-1].Structure -ne $true){'Incomplete'}
            else{'Completed'}
        $rate=$null
        if ($samples.Count -and $lastChange -gt $samples[0].Ms -and $final.Length -gt $samples[0].Length) { $rate=[math]::Round(($final.Length-$samples[0].Length)/(($lastChange-$samples[0].Ms)/1000),1) }
        $lifecycle | Add-Member -NotePropertyMembers ([ordered]@{
            LastChangeMs=$lastChange; LongestPauseMs=[math]::Round($pause,1); WriteDurationMs=[math]::Round($lastChange-$lifecycle.FirstMs,1)
            MidWrite=@($samples | Where-Object { $_.Ms -lt $lastChange }); FinalLength=if($final){$final.Length}else{0L}
            FinalExtension=if($final){$final.Extension}else{$null}; CompletedMs=if($complete.Count){$complete[0].Ms}else{$null}
            BytesPerSecond=$rate; Outcome=$outcome })
    }
    [pscustomobject]@{ Lifecycles=@($all | Sort-Object FirstMs); Owners=$owners }
}