Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
73 changes: 64 additions & 9 deletions src/Modules/SPSUpdate.Common/Public/Set-SPSSideBySideToken.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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 {
Expand Down
260 changes: 260 additions & 0 deletions tests/Set-SPSSideBySideToken.Tests.ps1
Original file line number Diff line number Diff line change
@@ -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
}
}
}
Loading