fix: handle BC table locks with proper retry and backoff

BC returns 500 with "being updated in a transaction done by another
session" when tables are locked by other users - this is normal during
business hours. Previously the script gave up after 3 quick retries.

Changes:
- Invoke-BCApi: increased retries to 10, table lock errors get longer
  waits (30s + 15s per attempt, up to 120s) vs generic errors (10s)
- Export-EntityData: retries the whole entity up to 5 times on table
  lock with 60s/120s/180s/240s waits between attempts
- Export-DocumentWithLines: same entity-level retry, restarts the
  full document+lines export on table lock rather than giving up
- Also handles 429 rate limiting with longer backoff (30s per attempt)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-16 09:41:17 +01:00
parent d08250a479
commit b407e2aeb7

View File

@@ -95,7 +95,7 @@ function Invoke-BCApi {
param( param(
[string]$Url, [string]$Url,
[int]$TimeoutSec = 120, [int]$TimeoutSec = 120,
[int]$MaxRetries = 3 [int]$MaxRetries = 10
) )
for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) { for ($attempt = 1; $attempt -le $MaxRetries; $attempt++) {
@@ -111,15 +111,39 @@ function Invoke-BCApi {
} }
catch { catch {
$statusCode = $null $statusCode = $null
$errorBody = ""
if ($_.Exception.Response) { if ($_.Exception.Response) {
$statusCode = [int]$_.Exception.Response.StatusCode $statusCode = [int]$_.Exception.Response.StatusCode
} }
if ($_.ErrorDetails.Message) {
$errorBody = $_.ErrorDetails.Message
}
# Table lock: BC returns 500 with "being updated in a transaction"
$isTableLock = $errorBody -match "transaction done by another session|being updated in a transaction|deadlock|locked"
# Rate limit
$isThrottled = ($statusCode -eq 429)
# Server error
$isServerError = ($statusCode -ge 500 -and -not $isTableLock)
# Timeout
$isTimeout = ($_ -match "Timeout")
$isRetryable = $isTableLock -or $isThrottled -or $isServerError -or $isTimeout
# Retry on 429 (throttled) or 5xx (server error) or timeout
$isRetryable = ($statusCode -eq 429) -or ($statusCode -ge 500) -or ($_ -match "Timeout")
if ($isRetryable -and $attempt -lt $MaxRetries) { if ($isRetryable -and $attempt -lt $MaxRetries) {
$wait = $attempt * 10 if ($isTableLock) {
Write-Log " Request failed (attempt $attempt/$MaxRetries), retrying in ${wait}s..." "WARN" # Table locks can last a while - wait longer with jitter
$wait = [math]::Min(30 + ($attempt * 15), 120)
Write-Log " Table lock detected (attempt $attempt/$MaxRetries), waiting ${wait}s..." "WARN"
}
elseif ($isThrottled) {
$wait = [math]::Min(30 * $attempt, 300)
Write-Log " Rate limited (attempt $attempt/$MaxRetries), waiting ${wait}s..." "WARN"
}
else {
$wait = [math]::Min(10 * $attempt, 120)
Write-Log " Request failed (attempt $attempt/$MaxRetries), retrying in ${wait}s..." "WARN"
}
Start-Sleep -Seconds $wait Start-Sleep -Seconds $wait
continue continue
} }
@@ -166,26 +190,41 @@ function Export-EntityData {
) )
$entityUrl = "$baseUrl/companies($CompanyId)/$EntityName" $entityUrl = "$baseUrl/companies($CompanyId)/$EntityName"
$maxEntityRetries = 5
Write-Log " Exporting $EntityName..." for ($entityAttempt = 1; $entityAttempt -le $maxEntityRetries; $entityAttempt++) {
Write-Log " Exporting $EntityName..."
try { try {
$data = Get-BCData -Url $entityUrl $data = Get-BCData -Url $entityUrl
$count = 0 $count = 0
if ($data) { $count = $data.Count } if ($data) { $count = $data.Count }
$outputFile = Join-Path $OutputDir "$EntityName.json" $outputFile = Join-Path $OutputDir "$EntityName.json"
$data | ConvertTo-Json -Depth 10 | Out-File -FilePath $outputFile -Encoding utf8 $data | ConvertTo-Json -Depth 10 | Out-File -FilePath $outputFile -Encoding utf8
Write-Log " $EntityName : $count records" Write-Log " $EntityName : $count records"
return $count return $count
} }
catch { catch {
Write-Log " Failed to export ${EntityName}: $_" "WARN" $errorMsg = "$_"
$outputFile = Join-Path $OutputDir "$EntityName.json" $isTableLock = $errorMsg -match "transaction done by another session|being updated in a transaction|deadlock|locked"
"[]" | Out-File -FilePath $outputFile -Encoding utf8
return 0 if ($isTableLock -and $entityAttempt -lt $maxEntityRetries) {
$wait = 60 * $entityAttempt
Write-Log " Table lock on $EntityName (attempt $entityAttempt/$maxEntityRetries), restarting in ${wait}s..." "WARN"
Start-Sleep -Seconds $wait
continue
}
Write-Log " Failed to export ${EntityName}: $errorMsg" "WARN"
$outputFile = Join-Path $OutputDir "$EntityName.json"
"[]" | Out-File -FilePath $outputFile -Encoding utf8
return 0
}
} }
return 0
} }
function Export-DocumentWithLines { function Export-DocumentWithLines {
@@ -197,85 +236,103 @@ function Export-DocumentWithLines {
[string]$OutputDir [string]$OutputDir
) )
Write-Log " Exporting $DocumentEntity (headers)..." # Retry the entire entity export if it fails (e.g. table lock on first page)
$maxEntityRetries = 5
$docFile = Join-Path $OutputDir "$DocumentEntity.jsonl" for ($entityAttempt = 1; $entityAttempt -le $maxEntityRetries; $entityAttempt++) {
$lineFile = Join-Path $OutputDir "$LineEntity.jsonl" Write-Log " Exporting $DocumentEntity (headers + lines)..."
[System.IO.File]::WriteAllText($docFile, "")
[System.IO.File]::WriteAllText($lineFile, "")
$docCount = 0 $docFile = Join-Path $OutputDir "$DocumentEntity.jsonl"
$lineCount = 0 $lineFile = Join-Path $OutputDir "$LineEntity.jsonl"
[System.IO.File]::WriteAllText($docFile, "")
[System.IO.File]::WriteAllText($lineFile, "")
try { $docCount = 0
# Step 1: Fetch document headers page by page (no $expand) $lineCount = 0
# BC API default page size is ~100, with @odata.nextLink for more $failed = $false
$currentUrl = "$baseUrl/companies($CompanyId)/$DocumentEntity"
while ($currentUrl) { try {
$response = Invoke-BCApi -Url $currentUrl # Step 1: Fetch document headers page by page (no $expand)
# BC API default page size is ~100, with @odata.nextLink for more
$currentUrl = "$baseUrl/companies($CompanyId)/$DocumentEntity"
if (-not $response.value -or $response.value.Count -eq 0) { while ($currentUrl) {
break $response = Invoke-BCApi -Url $currentUrl
}
# Step 2: For each document in this page, fetch its lines if (-not $response.value -or $response.value.Count -eq 0) {
foreach ($doc in $response.value) { break
$docCount++ }
$docId = $doc.id
# Write document header to disk # Step 2: For each document in this page, fetch its lines
$jsonLine = $doc | ConvertTo-Json -Depth 10 -Compress foreach ($doc in $response.value) {
[System.IO.File]::AppendAllText($docFile, $jsonLine + "`n") $docCount++
$docId = $doc.id
# Fetch lines for this document # Write document header to disk
$linesUrl = "$baseUrl/companies($CompanyId)/$DocumentEntity($docId)/$LineEntity" $jsonLine = $doc | ConvertTo-Json -Depth 10 -Compress
try { [System.IO.File]::AppendAllText($docFile, $jsonLine + "`n")
$linesResponse = Invoke-BCApi -Url $linesUrl -TimeoutSec 60
if ($linesResponse.value -and $linesResponse.value.Count -gt 0) {
foreach ($line in $linesResponse.value) {
$lineCount++
$lineJson = $line | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::AppendAllText($lineFile, $lineJson + "`n")
}
# Handle pagination within lines (unlikely but possible) # Fetch lines for this document
$nextLinesUrl = $linesResponse.'@odata.nextLink' $linesUrl = "$baseUrl/companies($CompanyId)/$DocumentEntity($docId)/$LineEntity"
while ($nextLinesUrl) { try {
$moreLinesResponse = Invoke-BCApi -Url $nextLinesUrl -TimeoutSec 60 $linesResponse = Invoke-BCApi -Url $linesUrl -TimeoutSec 60
if ($moreLinesResponse.value) { if ($linesResponse.value -and $linesResponse.value.Count -gt 0) {
foreach ($line in $moreLinesResponse.value) { foreach ($line in $linesResponse.value) {
$lineCount++ $lineCount++
$lineJson = $line | ConvertTo-Json -Depth 10 -Compress $lineJson = $line | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::AppendAllText($lineFile, $lineJson + "`n") [System.IO.File]::AppendAllText($lineFile, $lineJson + "`n")
} }
# Handle pagination within lines (unlikely but possible)
$nextLinesUrl = $linesResponse.'@odata.nextLink'
while ($nextLinesUrl) {
$moreLinesResponse = Invoke-BCApi -Url $nextLinesUrl -TimeoutSec 60
if ($moreLinesResponse.value) {
foreach ($line in $moreLinesResponse.value) {
$lineCount++
$lineJson = $line | ConvertTo-Json -Depth 10 -Compress
[System.IO.File]::AppendAllText($lineFile, $lineJson + "`n")
}
}
$nextLinesUrl = $moreLinesResponse.'@odata.nextLink'
} }
$nextLinesUrl = $moreLinesResponse.'@odata.nextLink'
} }
} }
} catch {
catch { Write-Log " Warning: failed to fetch lines for $DocumentEntity $docId : $_" "WARN"
Write-Log " Warning: failed to fetch lines for $DocumentEntity $docId : $_" "WARN" }
# Progress every 100 documents
if ($docCount % 100 -eq 0) {
Write-Log " Progress: $docCount documents, $lineCount lines"
}
} }
# Progress every 100 documents # Next page of documents
if ($docCount % 100 -eq 0) { $currentUrl = $response.'@odata.nextLink'
Write-Log " Progress: $docCount documents, $lineCount lines"
}
} }
# Next page of documents Write-Log " $DocumentEntity : $docCount documents, $lineCount lines (complete)"
$currentUrl = $response.'@odata.nextLink' return ($docCount + $lineCount)
} }
catch {
$errorMsg = "$_"
$isTableLock = $errorMsg -match "transaction done by another session|being updated in a transaction|deadlock|locked"
Write-Log " $DocumentEntity : $docCount documents, $lineCount lines (complete)" if ($isTableLock -and $entityAttempt -lt $maxEntityRetries) {
return ($docCount + $lineCount) $wait = 60 * $entityAttempt
} Write-Log " Table lock on $DocumentEntity (attempt $entityAttempt/$maxEntityRetries), restarting in ${wait}s..." "WARN"
catch { Start-Sleep -Seconds $wait
Write-Log " Failed to export ${DocumentEntity} at doc #$docCount : $_" "WARN" continue
Write-Log " Partial data saved ($docCount docs, $lineCount lines)" "WARN" }
return ($docCount + $lineCount)
Write-Log " Failed to export ${DocumentEntity} at doc #$docCount : $errorMsg" "WARN"
Write-Log " Partial data saved ($docCount docs, $lineCount lines)" "WARN"
return ($docCount + $lineCount)
}
} }
return 0
} }
# Main execution # Main execution