From 4dc9092ceed11e642b2d313136490b6a1fa827fa Mon Sep 17 00:00:00 2001 From: Roland Krummenacher Date: Wed, 16 Sep 2026 18:26:45 +0200 Subject: [PATCH 1/2] fix(open-data): sort ISF output ordinally so regeneration is host-independent The generator sorted with a bare Sort-Object, whose comparer follows the current culture. Culture-aware collation orders "Dasv4 Series" before "DCadsv5-series DedicatedHost" while an ordinal comparison puts the uppercase letter first, so the same records regenerated on a differently configured host reorder a few hundred rows without changing a value. A dispatch of the Update Instance Size Flexibility workflow after #2308 reproduced the published dataset exactly -- same 1,363 rows, same groups, same ratios -- and still pushed a 247-line diff that was pure reordering. Left alone, every scheduled run would push that diff whether or not any ISF data changed, burying real changes in review. #2308 pinned InvariantCulture on both parse sites and the write path but left the sort key culture-dependent. This applies the same treatment there, via an explicit [string]::CompareOrdinal comparer. Ordinal ordering is the order already committed, so InstanceSizeFlexibility.csv is unchanged. Adds four tests: group ordering, SKU ordering within a group, ordering held under de-DE, and an assertion that the published file is in ordinal order, which catches a file regenerated by hand on a differently configured host. Co-Authored-By: Claude Opus 5 --- .../Update-InstanceSizeFlexibility.Tests.ps1 | 71 +++++++++++++++++++ .../Update-InstanceSizeFlexibility.ps1 | 16 ++++- 2 files changed, 85 insertions(+), 2 deletions(-) diff --git a/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 b/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 index 0c23bda67..1aedb4370 100644 --- a/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 +++ b/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 @@ -365,6 +365,62 @@ Describe 'Update-InstanceSizeFlexibility' { } } + Context 'Sort order' { + # Culture-aware collation interleaves case and orders "Dasv4 Series" before + # "DCadsv5-series DedicatedHost"; an ordinal comparison puts the uppercase letter first. + # Sorting with the current culture makes the same records reorder between hosts, turning + # a no-op regeneration into a few hundred lines of diff that bury any real change. + It 'Orders flexibility groups ordinally rather than by the current culture' { + Mock Invoke-AzRestMethod { + New-CatalogResponse -Items @( + (New-CatalogItem -Sku 'Standard_D2as_v4' -Group 'Dasv4 Series' -Ratio '1'), + (New-CatalogItem -Sku 'dcadsv5 type 1' -Group 'DCadsv5-series DedicatedHost' -Ratio '1') + ) + } + + Invoke-Generator $baseParams + + @(Import-Csv $outFile).InstanceSizeFlexibilityGroup | + Should -Be @('DCadsv5-series DedicatedHost', 'Dasv4 Series') + } + + It 'Orders SKUs within a group ordinally' { + Mock Invoke-AzRestMethod { + New-CatalogResponse -Items @( + (New-CatalogItem -Sku 'Standard_Dadsv5' -Group 'Dadsv5 Series' -Ratio '1'), + (New-CatalogItem -Sku 'Standard_DC2ads_v5' -Group 'Dadsv5 Series' -Ratio '1') + ) + } + + Invoke-Generator $baseParams + + @(Import-Csv $outFile).ArmSkuName | Should -Be @('Standard_DC2ads_v5', 'Standard_Dadsv5') + } + + It 'Orders identically under a culture with different collation rules' { + $originalCulture = [System.Threading.Thread]::CurrentThread.CurrentCulture + try + { + [System.Threading.Thread]::CurrentThread.CurrentCulture = [System.Globalization.CultureInfo]::new('de-DE') + Mock Invoke-AzRestMethod { + New-CatalogResponse -Items @( + (New-CatalogItem -Sku 'Standard_D2as_v4' -Group 'Dasv4 Series' -Ratio '1'), + (New-CatalogItem -Sku 'dcadsv5 type 1' -Group 'DCadsv5-series DedicatedHost' -Ratio '1') + ) + } + + Invoke-Generator $baseParams + + @(Import-Csv $outFile).InstanceSizeFlexibilityGroup | + Should -Be @('DCadsv5-series DedicatedHost', 'Dasv4 Series') + } + finally + { + [System.Threading.Thread]::CurrentThread.CurrentCulture = $originalCulture + } + } + } + Context 'Published dataset' { It 'Has globally unique ArmSkuName values' { $csv = Import-Csv "$PSScriptRoot/../../../open-data/InstanceSizeFlexibility.csv" @@ -388,6 +444,21 @@ Describe 'Update-InstanceSizeFlexibility' { @($csv | Where-Object { [double]$_.Ratio -le 0 }).ArmSkuName | Should -BeNullOrEmpty } + It 'Is written in ordinal sort order' { + # Catches a file regenerated on a host whose culture collates differently, which + # reorders hundreds of rows without changing a single value. + $csv = @(Import-Csv "$PSScriptRoot/../../../open-data/InstanceSizeFlexibility.csv") + $sorted = [Collections.Generic.List[object]]::new([object[]]$csv) + $sorted.Sort([Comparison[object]] { + param($x, $y) + $byGroup = [string]::CompareOrdinal($x.InstanceSizeFlexibilityGroup, $y.InstanceSizeFlexibilityGroup) + if ($byGroup -ne 0) { return $byGroup } + [string]::CompareOrdinal($x.ArmSkuName, $y.ArmSkuName) + }) + + ($sorted.ArmSkuName -join "`n") | Should -BeExactly ($csv.ArmSkuName -join "`n") + } + It 'Has the documented three-column schema' { $csv = Import-Csv "$PSScriptRoot/../../../open-data/InstanceSizeFlexibility.csv" @($csv[0].PSObject.Properties.Name) | Should -Be @('InstanceSizeFlexibilityGroup', 'ArmSkuName', 'Ratio') diff --git a/src/scripts/Update-InstanceSizeFlexibility.ps1 b/src/scripts/Update-InstanceSizeFlexibility.ps1 index c6406267a..337ca7383 100644 --- a/src/scripts/Update-InstanceSizeFlexibility.ps1 +++ b/src/scripts/Update-InstanceSizeFlexibility.ps1 @@ -368,10 +368,22 @@ if (-not $Raw) $allRecords = Get-NormalizedRecords -Records $allRecords } +# Sorted ordinally rather than with Sort-Object, whose comparer follows the current culture. +# Culture-aware collation orders "DCadsv5-series DedicatedHost" after "Dasv4 Series" while an +# ordinal comparison puts it before, so the same records regenerated on a differently configured +# host produce a few hundred lines of pure reordering that bury any real change in review. +$sortedRecords = [Collections.Generic.List[object]]::new() +if ($allRecords) { $sortedRecords.AddRange([object[]]@($allRecords)) } # an empty sweep leaves $allRecords null +$sortedRecords.Sort([Comparison[object]] { + param($x, $y) + $byGroup = [string]::CompareOrdinal($x.InstanceSizeFlexibilityGroup, $y.InstanceSizeFlexibilityGroup) + if ($byGroup -ne 0) { return $byGroup } + [string]::CompareOrdinal($x.ArmSkuName, $y.ArmSkuName) + }) + # Ratio is formatted invariantly rather than left to Export-Csv, which uses the current culture: # on a comma-decimal machine it would publish "2,1" and break every consumer of the file. -$rows = $allRecords | - Sort-Object InstanceSizeFlexibilityGroup, ArmSkuName | +$rows = $sortedRecords | Select-Object InstanceSizeFlexibilityGroup, ArmSkuName, @{ Name = 'Ratio'; Expression = { $_.Ratio.ToString([Globalization.CultureInfo]::InvariantCulture) } } From 37f96abe4118f8361213218332942bedd277ddee Mon Sep 17 00:00:00 2001 From: Roland Krummenacher Date: Mon, 21 Sep 2026 09:08:13 +0200 Subject: [PATCH 2/2] docs(open-data): document why ordinal sort ties are impossible Addresses review feedback on the ISF ordinal sort: - Record why List.Sort's instability cannot surface here: $seen is keyed by flexibility group + ArmSkuName and a duplicate ArmSkuName across groups throws, so the two sort keys order every record uniquely in the normalized and the -Raw path alike. No tiebreaker is reachable. - Note that the comparer in the published-file order test is a copy of the script's and has to stay in sync with it. Comments only; no behavior change. 28/28 ISF tests pass. Co-Authored-By: Claude Opus 5 --- .../Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 | 5 ++++- src/scripts/Update-InstanceSizeFlexibility.ps1 | 5 +++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 b/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 index 1aedb4370..e44367463 100644 --- a/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 +++ b/src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1 @@ -446,7 +446,10 @@ Describe 'Update-InstanceSizeFlexibility' { It 'Is written in ordinal sort order' { # Catches a file regenerated on a host whose culture collates differently, which - # reorders hundreds of rows without changing a single value. + # reorders hundreds of rows without changing a single value. The comparer mirrors the + # one in src/scripts/Update-InstanceSizeFlexibility.ps1 and must stay in sync with it: + # if the script gains a sort key, this test keeps asserting the old order until it does + # too. $csv = @(Import-Csv "$PSScriptRoot/../../../open-data/InstanceSizeFlexibility.csv") $sorted = [Collections.Generic.List[object]]::new([object[]]$csv) $sorted.Sort([Comparison[object]] { diff --git a/src/scripts/Update-InstanceSizeFlexibility.ps1 b/src/scripts/Update-InstanceSizeFlexibility.ps1 index 337ca7383..d1123c316 100644 --- a/src/scripts/Update-InstanceSizeFlexibility.ps1 +++ b/src/scripts/Update-InstanceSizeFlexibility.ps1 @@ -372,6 +372,11 @@ if (-not $Raw) # Culture-aware collation orders "DCadsv5-series DedicatedHost" after "Dasv4 Series" while an # ordinal comparison puts it before, so the same records regenerated on a differently configured # host produce a few hundred lines of pure reordering that bury any real change in review. +# +# List.Sort is unstable where Sort-Object is stable, but no tie can reach it: $seen is keyed by +# group + ArmSkuName, and a duplicate ArmSkuName across groups throws above, so the two keys order +# every record uniquely in the normalized and the -Raw path alike. Keep the comparer in sync with +# the published-file order test in src/powershell/Tests/Unit/Update-InstanceSizeFlexibility.Tests.ps1. $sortedRecords = [Collections.Generic.List[object]]::new() if ($allRecords) { $sortedRecords.AddRange([object[]]@($allRecords)) } # an empty sweep leaves $allRecords null $sortedRecords.Sort([Comparison[object]] {