#Requires -RunAsAdministrator
<#
.SYNOPSIS
Windows 11 25H2 Search Indexing Progress Monitor
.PARAMETER RefreshSeconds
Poll interval in seconds. Default: 30.
.EXAMPLE
.\Watch-SearchIndexing.ps1
.\Watch-SearchIndexing.ps1 -RefreshSeconds 30
#>
param([int]$RefreshSeconds = 30)
$HandleExe = "C:\Program Files (x86)\Sysinternals\Handle\handle64.exe"
$BaseDir = "$env:ProgramData\Microsoft\Search\Data\Applications\Windows"
$Files = @{
DB = Join-Path $BaseDir "Windows.db"
GatherDB = Join-Path $BaseDir "Windows-gather.db"
WAL = Join-Path $BaseDir "Windows.db-wal"
GatherWAL = Join-Path $BaseDir "Windows-gather.db-wal"
}
# Always display in KB so small changes are visible
function Format-KB {
param([long]$Bytes)
return "{0:N0} KB" -f ($Bytes / 1KB)
}
function Format-Rate {
param([double]$BytesPerSec)
if ($BytesPerSec -le 0) { return "0 KB/min" }
if ($BytesPerSec -ge 1MB) { return "{0:N2} MB/min" -f ($BytesPerSec * 60 / 1MB) }
return "{0:N0} KB/min" -f ($BytesPerSec * 60 / 1KB)
}
function Format-Age {
param([double]$Seconds)
if ($Seconds -lt 60) { return "$([int]$Seconds)s ago" }
if ($Seconds -lt 3600) { return "$([int]($Seconds/60))m $([int]($Seconds%60))s ago" }
return "$([int]($Seconds/3600))h $([int](($Seconds%3600)/60))m ago"
}
function Get-FileInfo {
param([string]$Path)
try {
$f = Get-Item $Path -ErrorAction Stop
return [PSCustomObject]@{ Size=$f.Length; LastWrite=$f.LastWriteTime }
} catch {
return [PSCustomObject]@{ Size=0L; LastWrite=$null }
}
}
# Query handle64 across all search-related processes
# PST indexing runs in searchprotocolhost.exe, not searchindexer.exe
$SearchProcesses = @("searchindexer", "searchprotocolhost", "searchfilterhost")
function Get-CurrentIndexFile {
if (-not (Test-Path $HandleExe)) { return $null }
try {
# Single pipeline — avoids $Matches persistence issues in PowerShell 7
$pst = & $HandleExe -accepteula -p searchprotocolhost 2>$null | ForEach-Object {
if ($_ -match 'File\s+\([RW\-]+\)\s+([A-Za-z]:\\[^\r\n]+)') { $Matches[1].Trim() }
} | Where-Object {
$_ -notlike "*\Windows\*" -and
$_ -notlike "*\Microsoft\Search\*" -and
$_ -notlike "*\Microsoft\Windows\*" -and
$_ -notlike "*.dll" -and
$_ -notlike "*.mui" -and
$_ -notlike "*.db" -and
$_ -notlike "*.db-wal" -and
$_ -notlike "*.db-shm"
} | Where-Object { $_ -like "*.pst" -or $_ -like "*.ost" } | Select-Object -Last 1
return $pst
} catch { return $null }
}
# ── State tracking ────────────────────────────────────────────────────────────
# Activity is detected by ANY of:
# - WAL size change (growing or shrinking/checkpointing)
# - WAL timestamp change (in-place writes)
# - DB timestamp change (in-place page updates)
$samples = [System.Collections.Generic.List[PSCustomObject]]::new()
$lastActivityTime = Get-Date
$lastWalSize = -1L
$lastWalWrite = $null
$lastDbWrite = $null
$lastGatherWrite = $null
$lastFile = $null
$lastFileTime = $null
$stallThreshold = 120 # seconds
$iteration = 0
$walCheckpoints = 0
$lastCheckpointTime = $null
$checkpointIntervals = [System.Collections.Generic.List[double]]::new()
$dbSizeAtCheckpoint = 0L
$dbSizeAtStart = 0L
$monitorStartTime = Get-Date
# Accept handle EULA silently
if (Test-Path $HandleExe) { & $HandleExe -accepteula 2>$null | Out-Null }
Write-Host "Monitoring search index... (Ctrl+C to stop)" -ForegroundColor White
Start-Sleep -Seconds 2
while ($true) {
$iteration++
$now = Get-Date
if ($iteration -eq 1) { $dbSizeAtStart = (Get-FileInfo $Files.DB).Size; $monitorStartTime = $now }
# ── Read file info ────────────────────────────────────────────────────────
$dbInfo = Get-FileInfo $Files.DB
$gatherInfo = Get-FileInfo $Files.GatherDB
$walInfo = Get-FileInfo $Files.WAL
$gatherWalInfo = Get-FileInfo $Files.GatherWAL
$dbSize = $dbInfo.Size
$gatherSize = $gatherInfo.Size
$walSize = $walInfo.Size
$gatherWalSize = $gatherWalInfo.Size
$totalWal = $walSize + $gatherWalSize
# Detect WAL checkpoint — any reduction in WAL size indicates a checkpoint
if ($lastWalSize -gt 0 -and $totalWal -lt $lastWalSize) {
$walCheckpoints++
if ($lastCheckpointTime) {
$interval = ($now - $lastCheckpointTime).TotalSeconds
$checkpointIntervals.Add($interval)
}
$lastCheckpointTime = $now
$dbSizeAtCheckpoint = $dbSize
}
# ── Activity detection: use most recent file timestamp directly ────────────
# This avoids the poll interval inflating the "last activity" display
$activity = $false
if ($totalWal -ne $lastWalSize) { $activity = $true }
if ($walInfo.LastWrite -and $walInfo.LastWrite -ne $lastWalWrite) { $activity = $true }
if ($dbInfo.LastWrite -and $dbInfo.LastWrite -ne $lastDbWrite) { $activity = $true }
if ($gatherInfo.LastWrite -and $gatherInfo.LastWrite -ne $lastGatherWrite) { $activity = $true }
if ($activity) {
$lastWalSize = $totalWal
$lastWalWrite = $walInfo.LastWrite
$lastDbWrite = $dbInfo.LastWrite
$lastGatherWrite = $gatherInfo.LastWrite
}
# Most recent timestamp across all watched files — independent of poll interval
$timestamps = @($walInfo.LastWrite, $gatherWalInfo.LastWrite, $dbInfo.LastWrite, $gatherInfo.LastWrite) |
Where-Object { $_ -ne $null } |
Sort-Object -Descending
$mostRecentWrite = if ($timestamps) { $timestamps[0] } else { $lastActivityTime }
$stallSeconds = ($now - $mostRecentWrite).TotalSeconds
$isStalled = $stallSeconds -ge $stallThreshold
# ── Growth rate (DB size over sample window) ──────────────────────────────
$samples.Add([PSCustomObject]@{ Time=$now; DbSize=$dbSize; WalSize=$totalWal })
while ($samples.Count -gt 12) { $samples.RemoveAt(0) }
$dbRate = 0.0
if ($samples.Count -ge 2) {
$oldest = $samples[0]
$newest = $samples[$samples.Count - 1]
$elapsed = ($newest.Time - $oldest.Time).TotalSeconds
if ($elapsed -gt 0) {
$dbRate = ($newest.DbSize - $oldest.DbSize) / $elapsed
}
}
# ── Current file ──────────────────────────────────────────────────────────
$currentFile = Get-CurrentIndexFile
if ($currentFile -and $currentFile -ne $lastFile) {
$lastFile = $currentFile
$lastFileTime = $now
}
$fileAge = if ($lastFileTime) { ($now - $lastFileTime).TotalSeconds } else { $null }
# ── State ─────────────────────────────────────────────────────────────────
if ($isStalled) {
$state = "*** INDEXING STALLED ***"
$stateColor = "Red"
} elseif ($activity -or $totalWal -gt 100KB) {
$state = "Crawling"
$stateColor = "Yellow"
} else {
$state = "Idle"
$stateColor = "Green"
}
# ── Render ────────────────────────────────────────────────────────────────
Clear-Host
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host " Windows Search Index Progress" -ForegroundColor Cyan
Write-Host " $($now.ToString('HH:mm:ss')) poll #$iteration (every ${RefreshSeconds}s)" -ForegroundColor White
Write-Host ("=" * 64) -ForegroundColor Cyan
Write-Host ""
Write-Host (" {0,-26}" -f "State:") -ForegroundColor White -NoNewline
Write-Host $state -ForegroundColor $stateColor
if ($isStalled) {
$stallStr = "$([int]($stallSeconds/60))m $([int]($stallSeconds%60))s"
Write-Host (" {0,-26}{1}" -f "Stalled for:", $stallStr) -ForegroundColor Red
} else {
Write-Host (" {0,-26}{1}" -f "Last file write:", "$([int]$stallSeconds)s before last poll") -ForegroundColor White
}
Write-Host ""
# Current / last file
if ($currentFile) {
Write-Host (" {0,-26}{1}" -f "Current file:", $currentFile) -ForegroundColor White
} elseif ($lastFile) {
$col = if ($isStalled) { "Red" } else { "DarkGray" }
Write-Host (" {0,-26}{1}" -f "Last file seen:", $lastFile) -ForegroundColor $col
if ($fileAge) {
Write-Host (" {0,-26}{1}" -f "File last changed:", (Format-Age $fileAge)) -ForegroundColor White
}
} else {
Write-Host (" {0,-26}{1}" -f "Current file:", "(none visible yet)") -ForegroundColor White
}
Write-Host ""
Write-Host " -- Database files --" -ForegroundColor DarkCyan
$dbTs = if ($dbInfo.LastWrite) { $dbInfo.LastWrite.ToString('HH:mm:ss') } else { "n/a" }
$gatherTs = if ($gatherInfo.LastWrite) { $gatherInfo.LastWrite.ToString('HH:mm:ss') } else { "n/a" }
Write-Host (" {0,-26}{1,-16} modified {2}" -f "Windows.db:", (Format-KB $dbSize), $dbTs) -ForegroundColor White
Write-Host (" {0,-26}{1,-16} modified {2}" -f "Windows-gather.db:", (Format-KB $gatherSize), $gatherTs) -ForegroundColor White
Write-Host ""
Write-Host " -- WAL files --" -ForegroundColor DarkCyan
$walTs = if ($walInfo.LastWrite) { $walInfo.LastWrite.ToString('HH:mm:ss') } else { "n/a" }
$gatherWalTs= if ($gatherWalInfo.LastWrite) { $gatherWalInfo.LastWrite.ToString('HH:mm:ss') } else { "n/a" }
Write-Host (" {0,-26}{1,-16} modified {2}" -f "Windows.db-wal:", (Format-KB $walSize), $walTs) -ForegroundColor White
Write-Host (" {0,-26}{1,-16} modified {2}" -f "Windows-gather.db-wal:", (Format-KB $gatherWalSize), $gatherWalTs) -ForegroundColor White
$cpTime = if ($lastCheckpointTime) { $lastCheckpointTime.ToString("HH:mm:ss") } else { "none yet" }
$avgInterval = if ($checkpointIntervals.Count -gt 0) {
$avgSec = [int](($checkpointIntervals | Measure-Object -Sum).Sum / $checkpointIntervals.Count)
if ($avgSec -ge 60) { "$([int]($avgSec/60))m $($avgSec % 60)s" } else { "${avgSec}s" }
} else { "n/a" }
Write-Host (" {0,-43}{1}" -f "Last WAL checkpoint seen:", $cpTime) -ForegroundColor White
Write-Host (" {0,-43}{1}" -f "Number of WAL checkpoints detected:", $walCheckpoints) -ForegroundColor White
Write-Host (" {0,-43}{1}" -f "Avg interval between checkpoints:", $avgInterval) -ForegroundColor White
Write-Host ""
Write-Host " -- Growth rate --" -ForegroundColor DarkCyan
# Growth in KB since last poll (absolute, not per-minute rate)
$ratePoll = if ($samples.Count -ge 2) {
$prev = $samples[$samples.Count - 2]
$deltaKB = [math]::Round(($dbSize - $prev.DbSize) / 1KB, 0)
if ($deltaKB -gt 0) { "+{0:N0} KB" -f $deltaKB } elseif ($deltaKB -eq 0) { "+0 KB" } else { "{0:N0} KB" -f $deltaKB }
} else { "+0 KB" }
# Avg rate since monitor start
$elapsedTotal = ($now - $monitorStartTime).TotalSeconds
$rateAvg = if ($elapsedTotal -gt 0 -and $dbSizeAtStart -gt 0) {
Format-Rate (($dbSize - $dbSizeAtStart) / $elapsedTotal)
} else { "0 KB/min" }
$growthSinceStart = if ($dbSizeAtStart -gt 0) {
$deltaKB = [math]::Round(($dbSize - $dbSizeAtStart) / 1KB, 0)
if ($deltaKB -gt 0) { "+{0:N0} KB" -f $deltaKB } elseif ($deltaKB -eq 0) { "+0 KB" } else { "{0:N0} KB" -f $deltaKB }
} else { "+0 KB" }
Write-Host (" {0,-43}{1}" -f "DB growth since last poll:", $ratePoll) -ForegroundColor White
Write-Host (" {0,-43}{1}" -f "DB growth since monitor start:", $growthSinceStart) -ForegroundColor White
Write-Host (" {0,-43}{1}" -f "Avg DB growth rate since monitor start:", $rateAvg) -ForegroundColor White
Write-Host ""
Write-Host ("=" * 64) -ForegroundColor Cyan
$lastWalSize = $totalWal
Start-Sleep -Seconds $RefreshSeconds
}