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:
217
bc-export.ps1
217
bc-export.ps1
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user