From fb3bcff28a28fc205914f058fe03db7517990880 Mon Sep 17 00:00:00 2001 From: Andreas Jordan Date: Thu, 3 Sep 2026 15:49:30 +0200 Subject: [PATCH] Get-BulkRowsCopiedCount - Read the rows copied counter as Int64 The _rowsCopied field of SqlBulkCopy is an Int64 in current Microsoft.Data.SqlClient (the 4 byte counter of the legacy library, which Get-AdjustedTotalRowsCopied works around, was fixed upstream). The cast to [int] therefore throws above [int32]::MaxValue rows, the catch returned the -1 failure sentinel, and the callers fed that sentinel into the wrap adjustment as if it were a wrapped counter: a copy of 3 billion rows reported 4294967295 rows copied while the destination held the correct count. The callers now skip the final adjustment when the helper signals failure, so the running total from the notifications stands instead of a number that is off by billions. The wrap adjustment itself stays, it is still correct for genuinely wrapped values from an old library. Fixes #10675 (do Copy-DbaDbTableData, Import-DbaCsv, Import-DbaParquet, Get-BulkRowsCopiedCount) Co-Authored-By: Claude Fable 5 --- private/functions/Get-BulkRowsCopiedCount.ps1 | 8 ++- public/Copy-DbaDbTableData.ps1 | 8 ++- public/Import-DbaCsv.ps1 | 8 ++- public/Import-DbaParquet.ps1 | 8 ++- tests/Get-BulkRowsCopiedCount.Tests.ps1 | 52 +++++++++++++++++++ 5 files changed, 79 insertions(+), 5 deletions(-) create mode 100644 tests/Get-BulkRowsCopiedCount.Tests.ps1 diff --git a/private/functions/Get-BulkRowsCopiedCount.ps1 b/private/functions/Get-BulkRowsCopiedCount.ps1 index a5d4c21c2e9d..4ffbc78416cb 100644 --- a/private/functions/Get-BulkRowsCopiedCount.ps1 +++ b/private/functions/Get-BulkRowsCopiedCount.ps1 @@ -14,6 +14,7 @@ function Get-BulkRowsCopiedCount { - Copy-DbaDbTableData - Copy-DbaDbViewData - Import-DbaCsv + - Import-DbaParquet .EXAMPLE Get-BulkRowsCopied $bulkObject @@ -27,14 +28,17 @@ function Get-BulkRowsCopiedCount { Copyright: (c) 2020 by dbatools, licensed under MIT License: MIT https://opensource.org/licenses/MIT #> - [OutputType([int])] + [OutputType([long])] param ( [Microsoft.Data.SqlClient.SqlBulkCopy] $BulkCopy ) $BindingFlags = [Reflection.BindingFlags] "NonPublic,GetField,Instance" $rowsCopiedField = [Microsoft.Data.SqlClient.SqlBulkCopy].GetField("_rowsCopied", $BindingFlags) try { - return [int]$rowsCopiedField.GetValue($BulkCopy) + # The field is an Int64 in current Microsoft.Data.SqlClient (an Int32 in the legacy library), + # so it must not be narrowed to [int]: above [int32]::MaxValue rows that cast throws, and the + # -1 then taken for the row count inflates the reported total by billions of rows (see #10675). + return [long]$rowsCopiedField.GetValue($BulkCopy) } catch { return -1; } diff --git a/public/Copy-DbaDbTableData.ps1 b/public/Copy-DbaDbTableData.ps1 index 3ed8b1aba75a..ae6f220efff3 100644 --- a/public/Copy-DbaDbTableData.ps1 +++ b/public/Copy-DbaDbTableData.ps1 @@ -722,7 +722,13 @@ function Copy-DbaDbTableData { $bulkCopy.WriteToServer($reader) $finalRowCountReported = Get-BulkRowsCopiedCount $bulkCopy - $script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded + # -1 signals that the reflection lookup failed, not a wrapped counter, so it must not + # reach Get-AdjustedTotalRowsCopied: fed in as a row count it inflates the total by + # billions of rows (see #10675). The running total from the notifications is then the + # best number available. + if ($finalRowCountReported -ge 0) { + $script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded + } $RowsTotal = $script:totalRowsCopied $TotalTime = [math]::Round($elapsed.Elapsed.TotalSeconds, 1) diff --git a/public/Import-DbaCsv.ps1 b/public/Import-DbaCsv.ps1 index eb5da19bbc3c..d1f9ca2b2118 100644 --- a/public/Import-DbaCsv.ps1 +++ b/public/Import-DbaCsv.ps1 @@ -1493,7 +1493,13 @@ WHERE c.object_id = OBJECT_ID(@tableName) $finalRowCountReported = Get-BulkRowsCopiedCount $bulkCopy - $script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded + # -1 signals that the reflection lookup failed, not a wrapped counter, so it must not + # reach Get-AdjustedTotalRowsCopied: fed in as a row count it inflates the total by + # billions of rows (see #10675). The running total from the notifications is then the + # best number available. + if ($finalRowCountReported -ge 0) { + $script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded + } if ($completed) { Write-Progress -Id 1 -Activity "Inserting $($script:totalRowsCopied) rows" -Status "Complete" -Completed diff --git a/public/Import-DbaParquet.ps1 b/public/Import-DbaParquet.ps1 index c32fa0cdf746..a2086ed7f200 100644 --- a/public/Import-DbaParquet.ps1 +++ b/public/Import-DbaParquet.ps1 @@ -1031,7 +1031,13 @@ WHERE c.object_id = OBJECT_ID(@tableName) $finalRowCountReported = Get-BulkRowsCopiedCount $bulkCopy - $script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded + # -1 signals that the reflection lookup failed, not a wrapped counter, so it must not + # reach Get-AdjustedTotalRowsCopied: fed in as a row count it inflates the total by + # billions of rows (see #10675). The running total from the notifications is then the + # best number available. + if ($finalRowCountReported -ge 0) { + $script:totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $script:prevRowsCopied).NewRowCountAdded + } if ($completed) { Write-Progress -Id 1 -Activity "Inserting $($script:totalRowsCopied) rows" -Status "Complete" -Completed diff --git a/tests/Get-BulkRowsCopiedCount.Tests.ps1 b/tests/Get-BulkRowsCopiedCount.Tests.ps1 new file mode 100644 index 000000000000..140e367eb570 --- /dev/null +++ b/tests/Get-BulkRowsCopiedCount.Tests.ps1 @@ -0,0 +1,52 @@ +#Requires -Module @{ ModuleName="Pester"; ModuleVersion="5.0" } +param( + $ModuleName = "dbatools", + $CommandName = "Get-BulkRowsCopiedCount", + $PSDefaultParameterValues = $TestConfig.Defaults +) + +Describe $CommandName -Tag UnitTests { + Context "Reading the rows copied counter" { + It "Returns the counter value below Int32.MaxValue" { + InModuleScope "dbatools" { + $bulkCopy = New-Object -TypeName Microsoft.Data.SqlClient.SqlBulkCopy -ArgumentList "Server=dummy" + $rowsCopiedField = [Microsoft.Data.SqlClient.SqlBulkCopy].GetField("_rowsCopied", [Reflection.BindingFlags]"NonPublic,GetField,Instance") + $rowsCopiedField.SetValue($bulkCopy, [long]100000) + + Get-BulkRowsCopiedCount $bulkCopy | Should -Be 100000 + } + } + + It "Returns the counter value above Int32.MaxValue" { + # The field is an Int64 in current Microsoft.Data.SqlClient. A cast to [int] throws above + # [int32]::MaxValue, the catch then returned -1, and the callers took that for a wrapped + # counter and inflated the reported total by billions of rows (#10675). + InModuleScope "dbatools" { + $bulkCopy = New-Object -TypeName Microsoft.Data.SqlClient.SqlBulkCopy -ArgumentList "Server=dummy" + $rowsCopiedField = [Microsoft.Data.SqlClient.SqlBulkCopy].GetField("_rowsCopied", [Reflection.BindingFlags]"NonPublic,GetField,Instance") + $rowsCopiedField.SetValue($bulkCopy, [long]3000000000) + + Get-BulkRowsCopiedCount $bulkCopy | Should -Be 3000000000 + } + } + + It "Keeps the total of the callers correct above Int32.MaxValue" { + # The pattern of Copy-DbaDbTableData, Import-DbaCsv and Import-DbaParquet: the running total + # from the copy notifications, plus the adjustment for the rows after the last notification. + InModuleScope "dbatools" { + $bulkCopy = New-Object -TypeName Microsoft.Data.SqlClient.SqlBulkCopy -ArgumentList "Server=dummy" + $rowsCopiedField = [Microsoft.Data.SqlClient.SqlBulkCopy].GetField("_rowsCopied", [Reflection.BindingFlags]"NonPublic,GetField,Instance") + $rowsCopiedField.SetValue($bulkCopy, [long]3000000000) + + $totalRowsCopied = [long]2999995000 + $prevRowsCopied = [long]2999995000 + $finalRowCountReported = Get-BulkRowsCopiedCount $bulkCopy + if ($finalRowCountReported -ge 0) { + $totalRowsCopied += (Get-AdjustedTotalRowsCopied -ReportedRowsCopied $finalRowCountReported -PreviousRowsCopied $prevRowsCopied).NewRowCountAdded + } + + $totalRowsCopied | Should -Be 3000000000 + } + } + } +}