diff --git a/CHANGELOG.md b/CHANGELOG.md index f4d24e6..c2f8986 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- `Set-SPSSideBySideToken` now retries transient SharePoint `SPWebService.Update()` conflicts with a bounded policy (6 attempts, 15 seconds between attempts) instead of failing on the first race with concurrent `PSCONFIG` writes. The retry is conflict-specific: non-conflict errors still fail fast, and a conflict that persists through the budget is surfaced as a failure. ([#79](https://github.com/luigilink/SPSUpdate/issues/79)) + ## [5.3.0] - 2026-10-08 ### Changed diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 index b631c9e..5561082 100644 --- a/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1 @@ -11,29 +11,82 @@ $EnableSideBySide ) + $maxUpdateConflictAttempts = 6 + $updateConflictRetryDelaySeconds = 15 + + function Invoke-SPSWebServiceUpdateWithRetry { + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $WebAppName, + + [Parameter(Mandatory = $true)] + [System.String] + $Operation, + + [Parameter(Mandatory = $true)] + [System.String] + $TargetName, + + [Parameter(Mandatory = $true)] + [ScriptBlock] + $ApplyChange + ) + + $attempt = 0 + while ($true) { + $attempt++ + try { + $currentWebApp = Get-SPWebApplication -Identity $WebAppName -ErrorAction SilentlyContinue + if ($null -eq $currentWebApp -or $null -eq $currentWebApp.WebService) { + throw "Could not refresh SPWebApplication '$WebAppName' before '$Operation'." + } + + & $ApplyChange $currentWebApp.WebService + $currentWebApp.WebService.Update() + return $currentWebApp + } + catch { + $isConflict = $_.Exception.Message -match '(?i)update conflict has occurred' + if (-not $isConflict -or $attempt -ge $maxUpdateConflictAttempts) { + throw + } + + Write-Warning "SPWebService update conflict during '$Operation' on '$TargetName' (attempt $attempt/$maxUpdateConflictAttempts). Retrying in $updateConflictRetryDelaySeconds seconds." + Start-Sleep -Seconds $updateConflictRetryDelaySeconds + } + } + } + $webApps = Get-SPWebApplication -ErrorAction SilentlyContinue if ($null -ne $webApps) { foreach ($webApp in $webApps) { $spWebAppName = $webApp.Name + $currentWebApp = $webApp if ($EnableSideBySide) { - if ($webApp.WebService.EnableSideBySide) { + if ($currentWebApp.WebService.EnableSideBySide) { Write-Output "EnableSideBySide is already enabled on $spWebAppName Web Application" } else { Write-Output "Enabling EnableSideBySide on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, 'Enable SharePoint side-by-side mode')) { - $webApp.WebService.EnableSideBySide = $true - $webApp.WebService.Update() + $currentWebApp = Invoke-SPSWebServiceUpdateWithRetry -WebAppName $spWebAppName -Operation 'EnableSideBySide' -TargetName $spWebAppName -ApplyChange { + param($webService) + $webService.EnableSideBySide = $true + } } } - if ($webApp.WebService.SideBySideToken -eq $BuildVersion) { + if ($currentWebApp.WebService.SideBySideToken -eq $BuildVersion) { Write-Output "SideBySideToken $BuildVersion is already enabled on $spWebAppName Web Application" } else { Write-Output "Enabling SideBySideToken $BuildVersion on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, "Set SharePoint SideBySideToken to $BuildVersion")) { - $webApp.WebService.SideBySideToken = $BuildVersion - $webApp.WebService.Update() + $currentWebApp = Invoke-SPSWebServiceUpdateWithRetry -WebAppName $spWebAppName -Operation 'Set SideBySideToken' -TargetName $spWebAppName -ApplyChange { + param($webService) + $webService.SideBySideToken = $BuildVersion + } } } Write-Output 'Running CmdLet Copy-SPSideBySideFiles' @@ -42,11 +95,13 @@ } } else { - if ($webApp.WebService.EnableSideBySide) { + if ($currentWebApp.WebService.EnableSideBySide) { Write-Output "Disabling EnableSideBySide on $spWebAppName Web Application" if ($PSCmdlet.ShouldProcess($spWebAppName, 'Disable SharePoint side-by-side mode')) { - $webApp.WebService.EnableSideBySide = $false - $webApp.WebService.Update() + $currentWebApp = Invoke-SPSWebServiceUpdateWithRetry -WebAppName $spWebAppName -Operation 'DisableSideBySide' -TargetName $spWebAppName -ApplyChange { + param($webService) + $webService.EnableSideBySide = $false + } } } else { diff --git a/tests/Set-SPSSideBySideToken.Tests.ps1 b/tests/Set-SPSSideBySideToken.Tests.ps1 new file mode 100644 index 0000000..a219336 --- /dev/null +++ b/tests/Set-SPSSideBySideToken.Tests.ps1 @@ -0,0 +1,260 @@ +# Behavioural tests for Set-SPSSideBySideToken retry semantics on transient +# SPWebService update conflicts. + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1' + Import-Module -Name $modulePath -Force + + function New-TestWebApp { + param + ( + [Parameter(Mandatory = $true)] + [System.Boolean] + $EnableSideBySide, + + [Parameter(Mandatory = $true)] + [System.String] + $SideBySideToken, + + [Parameter(Mandatory = $true)] + [System.Int32] + $FailCount, + + [Parameter(Mandatory = $true)] + [System.String] + $ErrorMessage + ) + + $webService = [pscustomobject]@{ + EnableSideBySide = $EnableSideBySide + SideBySideToken = $SideBySideToken + AttemptCount = 0 + FailCount = $FailCount + UpdateErrorMessage = $ErrorMessage + } + + Add-Member -InputObject $webService -MemberType ScriptMethod -Name Update -Value { + $this.AttemptCount++ + if ($this.AttemptCount -le $this.FailCount) { + throw [System.Exception]::new($this.UpdateErrorMessage) + } + } -Force + + return [pscustomobject]@{ + Name = 'http://portal' + WebService = $webService + } + } + + function global:Get-SPWebApplication { + [CmdletBinding()] + param( + [Parameter()] + [System.String] + $Identity + ) + + $script:GetSpWebAppCallCount++ + if ($PSBoundParameters.ContainsKey('Identity')) { + $script:GetSpWebAppIdentityCallCount++ + if ($null -ne $script:IdentityResolver) { + return & $script:IdentityResolver $Identity + } + if ($null -ne $script:WebAppsByIdentity -and $script:WebAppsByIdentity.ContainsKey($Identity)) { + return $script:WebAppsByIdentity[$Identity] + } + return $null + } + + return $script:WebApps + } + + function global:Copy-SPSideBySideFiles { + $script:CopyCalls++ + } +} + +AfterAll { + Remove-Item -Path Function:\global:Get-SPWebApplication -ErrorAction SilentlyContinue + Remove-Item -Path Function:\global:Copy-SPSideBySideFiles -ErrorAction SilentlyContinue + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Set-SPSSideBySideToken - bounded retry on update conflict' { + BeforeEach { + $script:WebApps = @() + $script:WebAppsByIdentity = @{} + $script:IdentityResolver = $null + $script:StaleWebApp = $null + $script:FreshWebApp = $null + $script:SwappedToFresh = $false + $script:CopyCalls = 0 + $script:GetSpWebAppCallCount = 0 + $script:GetSpWebAppIdentityCallCount = 0 + } + + It 'retries update-conflict exceptions and then succeeds' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Not -Throw + + $webApp.WebService.AttemptCount | Should -Be 3 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 1 + $webApp.WebService.SideBySideToken | Should -Be '16.0.20326.20190' + } + + It 'fails after 6 attempts when conflict persists' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*update conflict has occurred*' + + $webApp.WebService.AttemptCount | Should -Be 6 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'does not retry non-conflict exceptions' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 1 -ErrorMessage 'Access denied.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*Access denied*' + + $webApp.WebService.AttemptCount | Should -Be 1 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 0 -Exactly + } + + It 'refreshes a stale SPWebService instance between retries' { + $stale = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $fresh = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.10000.10000' -FailCount 0 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($stale) + $script:WebAppsByIdentity = @{ 'http://portal' = $stale } + $script:SwappedToFresh = $false + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith { + if (-not $script:SwappedToFresh) { + $script:WebAppsByIdentity['http://portal'] = $fresh + $script:SwappedToFresh = $true + } + } + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Not -Throw + + $stale.WebService.AttemptCount | Should -Be 1 + $fresh.WebService.AttemptCount | Should -Be 1 + $script:GetSpWebAppIdentityCallCount | Should -Be 2 + $script:SwappedToFresh | Should -BeTrue + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 1 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 1 + } + + Context 'enable/disable mode transition callbacks' { + It 'retries and succeeds when enabling side-by-side mode hits a transient conflict' { + $webApp = New-TestWebApp -EnableSideBySide $false -SideBySideToken '16.0.20326.20190' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Not -Throw + + $webApp.WebService.AttemptCount | Should -Be 3 + $webApp.WebService.EnableSideBySide | Should -BeTrue + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 1 + } + + It 'fails after 6 attempts when enabling side-by-side mode keeps conflicting' { + $webApp = New-TestWebApp -EnableSideBySide $false -SideBySideToken '16.0.20326.20190' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*update conflict has occurred*' + + $webApp.WebService.AttemptCount | Should -Be 6 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'does not retry non-conflict errors when enabling side-by-side mode' { + $webApp = New-TestWebApp -EnableSideBySide $false -SideBySideToken '16.0.20326.20190' -FailCount 1 -ErrorMessage 'Access denied.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $true -Confirm:$false } | + Should -Throw -ExpectedMessage '*Access denied*' + + $webApp.WebService.AttemptCount | Should -Be 1 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 0 -Exactly + $script:CopyCalls | Should -Be 0 + } + + It 'retries and succeeds when disabling side-by-side mode hits a transient conflict' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.20326.20190' -FailCount 2 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $false -Confirm:$false } | + Should -Not -Throw + + $webApp.WebService.AttemptCount | Should -Be 3 + $webApp.WebService.EnableSideBySide | Should -BeFalse + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 2 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'fails after 6 attempts when disabling side-by-side mode keeps conflicting' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.20326.20190' -FailCount 99 -ErrorMessage 'An update conflict has occurred, and you must re-try this action.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $false -Confirm:$false } | + Should -Throw -ExpectedMessage '*update conflict has occurred*' + + $webApp.WebService.AttemptCount | Should -Be 6 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 5 -Exactly -ParameterFilter { $Seconds -eq 15 } + $script:CopyCalls | Should -Be 0 + } + + It 'does not retry non-conflict errors when disabling side-by-side mode' { + $webApp = New-TestWebApp -EnableSideBySide $true -SideBySideToken '16.0.20326.20190' -FailCount 1 -ErrorMessage 'Access denied.' + $script:WebApps = @($webApp) + $script:WebAppsByIdentity = @{ 'http://portal' = $webApp } + + Mock -CommandName Start-Sleep -ModuleName SPSUpdate.Common -MockWith {} + + { Set-SPSSideBySideToken -BuildVersion '16.0.20326.20190' -EnableSideBySide $false -Confirm:$false } | + Should -Throw -ExpectedMessage '*Access denied*' + + $webApp.WebService.AttemptCount | Should -Be 1 + Should -Invoke -CommandName Start-Sleep -ModuleName SPSUpdate.Common -Times 0 -Exactly + $script:CopyCalls | Should -Be 0 + } + } +}