11# Copyright (c) Microsoft Corporation.
22# Licensed under the MIT License.
33
4+ [Diagnostics.CodeAnalysis.SuppressMessageAttribute (' PSUseUsingScopeModifierInNewRunspaces' , ' ' , Justification = ' Variable passed via -ArgumentList param()' )]
45[CmdletBinding ()]
56param (
67 [switch ]
@@ -18,11 +19,10 @@ begin {
1819 throw " Pester module could not be loaded"
1920 }
2021
21- $jobsQueued = New-Object ' System.Collections.Generic.Queue[object]'
22- $childIds = 1
2322 $jobsCompleted = @ {}
2423 $jobsProgress = @ {}
25- $jobsRunning = @ ()
24+ $jobsRunning = New-Object ' System.Collections.Generic.List[PSCustomObject]'
25+ $childIds = 1
2626 # on Azure pipeline we have noticed 2 or 4 cores available. to get the most of out jobs, need at least a min of 2 threads running.
2727 $jobQueueMaxConcurrency = [System.Math ]::Max(([System.Math ]::Min(([System.Environment ]::ProcessorCount - 1 ), 5 )), 2 )
2828 Write-Host " Max Job Threads: $jobQueueMaxConcurrency "
@@ -44,69 +44,139 @@ begin {
4444 $scripts = @ (Get-ChildItem - Recurse $root |
4545 Where-Object { $_.Name -like " *.Tests.ps1" -and $_.FullName -notmatch " \.github" })
4646
47+ # Categorize test files by priority using comment tag: # PesterPriority: High
48+ $highPriorityQueue = New-Object ' System.Collections.Generic.Queue[object]'
49+ $lowPriorityQueue = New-Object ' System.Collections.Generic.Queue[object]'
50+
51+ foreach ($script in $scripts ) {
52+ $firstLines = Get-Content $script.FullName - TotalCount 15
53+ if ($firstLines -match " # PesterPriority: High" ) {
54+ $highPriorityQueue.Enqueue ($script )
55+ } else {
56+ $lowPriorityQueue.Enqueue ($script )
57+ }
58+ }
59+
60+ Write-Host " High Priority Tests: $ ( $highPriorityQueue.Count ) | Low Priority Tests: $ ( $lowPriorityQueue.Count ) "
61+
62+ # Thread allocation: high priority gets up to (threads - 1) slots, always leaving at least 1 for low.
63+ # This ensures heavy tests start immediately while small tests still make progress.
64+ $highThreadMax = [System.Math ]::Min($highPriorityQueue.Count , $jobQueueMaxConcurrency - 1 )
65+ $lowThreadMax = $jobQueueMaxConcurrency - $highThreadMax
66+
67+ # If no high priority tests exist, all threads go to low priority
68+ if ($highPriorityQueue.Count -eq 0 ) {
69+ $lowThreadMax = $jobQueueMaxConcurrency
70+ $highThreadMax = 0
71+ }
72+
73+ Write-Host " Thread Allocation: High=$highThreadMax Low=$lowThreadMax "
74+
4775 $parentProgress = @ {
4876 Id = 0
4977 Activity = " Running Pester Tests"
50- Status = [ string ]::Empty
78+ Status = " Initializing "
5179 PercentComplete = 0
5280 }
5381
54- $scripts | ForEach-Object {
55- $jobsQueued.Enqueue (@ {
56- ScriptBlock = {
57- param (
58- [string ]$FileName
59- )
60- return Invoke-Pester - Path $FileName - PassThru
61- }
62- ArgumentList = $_.FullName
63- Name = $_.Name
64- })
65- }
66-
67- $parentProgress.PercentComplete = ($jobsCompleted.Count / $scripts.Count * 100 )
68- $parentProgress.Status = " Number of Jobs Running $ ( $jobsRunning.Count ) "
69-
7082 if (-not $NoProgress ) {
7183 Write-Progress @parentProgress
7284 }
7385
74- while ($jobsQueued.Count -gt 0 -or $jobsRunning.Count -gt 0 ) {
86+ while ($highPriorityQueue.Count -gt 0 -or $lowPriorityQueue.Count -gt 0 -or $jobsRunning.Count -gt 0 ) {
87+
88+ # Determine if low priority work is fully drained (queue empty AND no low jobs running)
89+ $lowDrained = ($lowPriorityQueue.Count -eq 0 -and
90+ (@ ($jobsRunning | Where-Object { $_.Priority -eq " Low" }).Count -eq 0 ))
91+
92+ $runningHigh = @ ($jobsRunning | Where-Object { $_.Priority -eq " High" }).Count
93+ $runningLow = @ ($jobsRunning | Where-Object { $_.Priority -eq " Low" }).Count
94+
95+ # Once low priority is drained, all threads become available for high priority
96+ $effectiveHighMax = if ($lowDrained ) { $jobQueueMaxConcurrency } else { $highThreadMax }
97+ $effectiveLowMax = if ($lowDrained ) { 0 } else { $lowThreadMax }
98+
99+ # Start new jobs up to concurrency limit
100+ while ($jobsRunning.Count -lt $jobQueueMaxConcurrency ) {
101+ $nextScript = $null
102+ $nextPriority = $null
103+
104+ # Try high priority first if under high thread limit
105+ if ($runningHigh -lt $effectiveHighMax -and $highPriorityQueue.Count -gt 0 ) {
106+ $nextScript = $highPriorityQueue.Dequeue ()
107+ $nextPriority = " High"
108+ } elseif ($runningLow -lt $effectiveLowMax -and $lowPriorityQueue.Count -gt 0 ) {
109+ $nextScript = $lowPriorityQueue.Dequeue ()
110+ $nextPriority = " Low"
111+ } elseif ($highPriorityQueue.Count -gt 0 -and $runningHigh -lt $jobQueueMaxConcurrency ) {
112+ # Fill any remaining slots with high priority
113+ $nextScript = $highPriorityQueue.Dequeue ()
114+ $nextPriority = " High"
115+ } elseif ($lowPriorityQueue.Count -gt 0 -and $runningLow -lt $jobQueueMaxConcurrency ) {
116+ # Fill any remaining slots with low priority
117+ $nextScript = $lowPriorityQueue.Dequeue ()
118+ $nextPriority = " Low"
119+ }
120+
121+ if ($null -eq $nextScript ) { break }
122+
123+ if ($VerbosePreference -eq " Continue" ) {
124+ $elapsed = [math ]::Round($stopWatch.Elapsed.TotalSeconds , 1 )
125+ Write-Verbose " [DEBUG T+${elapsed} s] Starting [$nextPriority ] $ ( $nextScript.Name ) (H:$runningHigh /$effectiveHighMax L:$runningLow /$effectiveLowMax )"
126+ }
127+
128+ $newJob = Start-Job - ScriptBlock {
129+ param ([string ]$FileName )
130+ return Invoke-Pester - Path $FileName - PassThru
131+ } - ArgumentList $nextScript.FullName - Name $nextScript.Name
132+
133+ $jobsRunning.Add ([PSCustomObject ]@ {
134+ Job = $newJob
135+ Priority = $nextPriority
136+ Name = $nextScript.Name
137+ })
75138
76- if ($jobsRunning.Count -lt $jobQueueMaxConcurrency -and $jobsQueued.Count -gt 0 ) {
77- $jobArgs = $jobsQueued.Dequeue ()
78- # Using Start-Job instead of Start-ThreadJob as this is faster for this script block
79- # If Start-ThreadJob is used, need to have $justFinished also use NotStarted State Filter
80- $newJob = Start-Job @jobArgs
81- $jobsRunning += $newJob
82139 $progress = @ {
83140 Id = $childIds ++
84141 ParentId = 0
85- Activity = " Running: $ ( $newJob .Name ) "
142+ Activity = " Running [ $nextPriority ] : $ ( $nextScript .Name ) "
86143 }
87144 $jobsProgress.Add ($newJob.Name , $progress )
88145
89146 if (-not $NoProgress ) {
90147 Write-Progress @progress
91148 }
149+
150+ $runningHigh = @ ($jobsRunning | Where-Object { $_.Priority -eq " High" }).Count
151+ $runningLow = @ ($jobsRunning | Where-Object { $_.Priority -eq " Low" }).Count
92152 }
93153
94- $justFinished = @ ($jobsRunning | Where-Object { $_.State -ne " Running" })
154+ # Check for completed jobs
155+ $justFinished = @ ($jobsRunning | Where-Object { $_.Job.State -ne " Running" })
95156
96157 if ($justFinished.Count -gt 0 ) {
97- foreach ($job in $justFinished ) {
98- $result = Receive-Job $job
99- $jobsCompleted.Add ($job.Name , [PSCustomObject ]@ {
100- Job = $job
101- Result = $result
158+ foreach ($item in $justFinished ) {
159+ $result = Receive-Job $item.Job
160+ $jobsCompleted.Add ($item.Name , [PSCustomObject ]@ {
161+ Job = $item.Job
162+ Priority = $item.Priority
163+ Result = $result
102164 })
103- $progress = $jobsProgress [$job .Name ]
165+ $progress = $jobsProgress [$item .Name ]
104166
105167 if (-not $NoProgress ) {
106168 Write-Progress @progress - Completed
107169 }
108- Write-Host $job.Name " job finished."
109- Remove-Job $job - Force
170+
171+ $jobDuration = [math ]::Round(($item.Job.PSEndTime - $item.Job.PSBeginTime ).TotalSeconds, 1 )
172+ Write-Host " [$ ( $item.Priority ) ] $ ( $item.Name ) job finished. (${jobDuration} s)"
173+
174+ if ($VerbosePreference -eq " Continue" ) {
175+ $elapsed = [math ]::Round($stopWatch.Elapsed.TotalSeconds , 1 )
176+ Write-Verbose " [DEBUG T+${elapsed} s] Completed [$ ( $item.Priority ) ] $ ( $item.Name ) - Pester: $ ( [math ]::Round($result.Duration.TotalSeconds , 1 )) s Job: ${jobDuration} s"
177+ }
178+
179+ Remove-Job $item.Job - Force
110180 $result
111181
112182 if ($result.Result -eq " Failed" -or
@@ -115,18 +185,28 @@ begin {
115185 }
116186 }
117187
118- $jobsRunning = @ ($jobsRunning | Where-Object { -not $justFinished.Contains ($_ ) })
188+ foreach ($item in $justFinished ) {
189+ $jobsRunning.Remove ($item ) | Out-Null
190+ }
119191 }
120192
193+ $highRunning = @ ($jobsRunning | Where-Object { $_.Priority -eq " High" }).Count
194+ $lowRunning = @ ($jobsRunning | Where-Object { $_.Priority -eq " Low" }).Count
195+ $highCompleted = @ ($jobsCompleted.Values | Where-Object { $_.Priority -eq " High" }).Count
196+ $lowCompleted = @ ($jobsCompleted.Values | Where-Object { $_.Priority -eq " Low" }).Count
197+ $highTotal = $highCompleted + $highRunning + $highPriorityQueue.Count
198+ $lowTotal = $lowCompleted + $lowRunning + $lowPriorityQueue.Count
199+
121200 $parentProgress.PercentComplete = ($jobsCompleted.Count / $scripts.Count * 100 )
122- $parentProgress.Status = " Number of Jobs Running $ ( $jobsRunning.Count ) "
201+ $parentProgress.Status = " Running: $ ( $jobsRunning.Count ) | High: $highRunning running, $highCompleted / $highTotal done | Low: $lowRunning running, $lowCompleted / $lowTotal done "
123202
124203 if (-not $NoProgress ) {
125204 Write-Progress @parentProgress
126205 }
127206
128- if ($jobsRunning.Count -eq $jobQueueMaxConcurrency -or $jobsQueued.Count -eq 0 ) {
129- Start-Sleep 1
207+ if ($jobsRunning.Count -ge $jobQueueMaxConcurrency -or
208+ ($highPriorityQueue.Count -eq 0 -and $lowPriorityQueue.Count -eq 0 )) {
209+ Start-Sleep - Milliseconds 500
130210 }
131211 }
132212} end {
@@ -144,7 +224,7 @@ begin {
144224 $value = $jobsCompleted [$job ]
145225 $totalSeconds = ($value.Job.PSEndTime - $value.Job.PSBeginTime ).TotalSeconds
146226 $sumTotalPesterSeconds += $value.Result.Duration.TotalSeconds
147- Write-Host " $job took $totalSeconds seconds to complete"
227+ Write-Host " [ $ ( $value .Priority ) ] $job took $totalSeconds seconds to complete"
148228 $sumTotalSeconds += $totalSeconds
149229
150230 if ($value.Result.Result -eq " Failed" ) {
@@ -157,9 +237,29 @@ begin {
157237 Write-Host
158238 Write-Host " Total seconds for jobs: $sumTotalSeconds "
159239 Write-Host " Total seconds for pester results: $sumTotalPesterSeconds "
160- Write-Host " Average seconds per threads allowed: $ ( $sumTotalSeconds / $jobQueueMaxConcurrency ) "
240+ Write-Host " Average seconds per threads allowed: $ ( $sumTotalSeconds / $jobQueueMaxConcurrency ) "
161241 Write-Host " Total Seconds script took: $ ( $stopWatch.Elapsed.TotalSeconds ) "
162242
243+ if ($VerbosePreference -eq " Continue" ) {
244+ $highJobs = $jobsCompleted.Values | Where-Object { $_.Priority -eq " High" }
245+ $lowJobs = $jobsCompleted.Values | Where-Object { $_.Priority -eq " Low" }
246+ $highSum = ($highJobs | ForEach-Object { ($_.Job.PSEndTime - $_.Job.PSBeginTime ).TotalSeconds } | Measure-Object - Sum).Sum
247+ $lowSum = ($lowJobs | ForEach-Object { ($_.Job.PSEndTime - $_.Job.PSBeginTime ).TotalSeconds } | Measure-Object - Sum).Sum
248+ $longestJob = $jobsCompleted.Values | Sort-Object { ($_.Job.PSEndTime - $_.Job.PSBeginTime ).TotalSeconds } | Select-Object - Last 1
249+ $longestDuration = [math ]::Round(($longestJob.Job.PSEndTime - $longestJob.Job.PSBeginTime ).TotalSeconds, 1 )
250+ $efficiency = [math ]::Round(($sumTotalSeconds / ($stopWatch.Elapsed.TotalSeconds * $jobQueueMaxConcurrency )) * 100 , 1 )
251+
252+ Write-Verbose " "
253+ Write-Verbose " === Scheduling Debug ==="
254+ Write-Verbose " High priority: $ ( $highJobs.Count ) jobs, $ ( [math ]::Round($highSum , 1 )) s total"
255+ Write-Verbose " Low priority: $ ( $lowJobs.Count ) jobs, $ ( [math ]::Round($lowSum , 1 )) s total"
256+ Write-Verbose " Longest job: [$ ( $longestJob.Priority ) ] $ ( $longestJob.Job.Name ) (${longestDuration} s)"
257+ Write-Verbose " Thread utilization: $efficiency % (ideal=100%)"
258+ Write-Verbose " Theoretical minimum: $ ( [math ]::Round($sumTotalSeconds / $jobQueueMaxConcurrency , 1 )) s"
259+ Write-Verbose " Actual wall clock: $ ( [math ]::Round($stopWatch.Elapsed.TotalSeconds , 1 )) s"
260+ Write-Verbose " Overhead: $ ( [math ]::Round($stopWatch.Elapsed.TotalSeconds - ($sumTotalSeconds / $jobQueueMaxConcurrency ), 1 )) s"
261+ }
262+
163263 if ($failPipeline ) {
164264 throw " Failed Pester Testing Results"
165265 }
0 commit comments