Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Concurrent s3 parts upload - #205

Open
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload
Open

Concurrent s3 parts upload#205
barnabehvrd wants to merge 10 commits into
pelican:mainfrom
barnabehvrd:async-s3-parts-upload

Conversation

@barnabehvrd

@barnabehvrdbarnabehvrd commented Aug 11, 2026

Copy link
Copy Markdown

Following PR pelican/panel#2510 on the Panel, this PR makes Wings upload S3 backup parts concurrently instead of one after another.
The number of parts in flight is bounded by the max_concurrent_uploads value returned by the Panel alongside the part URLs, and the upload stops as soon as any part fails permanently.

Uploading parts in parallel was not possible before: every part read from the same shared io.Reader, so concurrent reads would have interleaved and produced a corrupted archive.

Each part now gets its own io.SectionReader over the backup file, which is safe for concurrent use and lets a failed part be retried from its correct offset. Parts are uploaded through an errgroup whose limit comes from the max_concurrent_uploads value returned by the Panel alongside the part URLs. Results are written to a pre-allocated slice so the part list stays ordered regardless of completion order.

If the Panel does not include max_concurrent_uploads in its response (older Panel version) or sends an invalid value, Wings falls back to 1, preserving the previous sequential behavior. Upgrading Wings without upgrading the Panel is therefore safe.

This also fixes the retry path, which could never work: the request was built once outside the backoff loop, so a retry replayed a body that had already been consumed and sent an empty payload with a mismatched Content-Length.

The request is now rebuilt on every attempt and the section is rewound first.

The backoff's elapsed-time bound has been removed as well, since a single part can take longer than the previous one-minute budget, which meant a second attempt never happened. The two-hour request timeout and the retry count are unchanged.

Idle connections are now kept in the pool between parts instead of being renegotiated, and response bodies are drained so connections can be reused.

The results are as follows:

ConcurrencyDurationThroughputSpeedup
145.2 s23.2 MB/sbaseline
55.37 s195 MB/s8.4x
104.31 s244 MB/s10.5x
203.46 s303 MB/s13.1x

Note :
These results were obtained on a fresh GCP E2 instance (2vCPU / 4GB of RAM / roughly 3-4 Gbps bandwith) for a bakcup of ~ 1000 MiB saved at BackBlaze B2. All parts were 50MiB except the last one (just a few KiB)

The benchmark backup only contains ~20 parts, which caps the benefit of higher concurrency levels: at 20 concurrent uploads, half the backup is in flight at once, so the marginal gain flattens. On real-world backups (10-50+ GiB, hundreds of parts), a default of 10 keeps the upload pipeline full throughout the transfer without hammering the S3 endpoint.

Most of the gain is already there at the lower concurrency levels, and the highest setting only buys a little more while getting close to the link's capacity. The sequential baseline is also the median of several runs that varied widely: with a single connection the total time depends entirely on how that one connection happens to behave, and one stalled part goes straight into the total. Concurrent uploads absorb that, so the win is in predictability as much as in raw throughput.

Summary by CodeRabbit

  • New Features

    • Added support for reporting the maximum number of concurrent backup uploads.
    • Backup uploads now run concurrently with a controlled limit, improving throughput.
    • Added automatic retries for temporary upload failures, including rate limiting responses.
  • Reliability

    • Uploads now respond more effectively to cancellation and retry individual parts when needed.
    • Improved connection handling helps maintain reliable multipart uploads.

@barnabehvrd
barnabehvrd requested a review from a team as a code ownerAugust 11, 2026 20:00
@barnabehvrdbarnabehvrd changed the title Async s3 parts uploadConcurrent s3 parts uploadAug 11, 2026
@coderabbitai

coderabbitaiBot commented Aug 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The upload response now reports the maximum concurrent upload count. S3 multipart uploads now run with bounded concurrency, independent file sections, context cancellation, dedicated HTTP transport settings, and expanded retry handling.

Changes

Backup upload flow

Layer / File(s)Summary
Upload concurrency contract
remote/types.go
BackupRemoteUploadResponse now includes MaxConcurrentUploads serialized as max_concurrent_uploads.
Concurrent S3 multipart execution
server/backup/backup_s3.go
Multipart uploads use bounded errgroup workers and independent io.SectionReader instances. Requests use the provided context, rebuild bodies on retries, drain response bodies, retry HTTP 429 and 5xx responses, and detect wrapped backoff errors with errors.As.

Estimated code review effort: 4 (Complex) | ~45 minutes

Suggested reviewers:quintenqvd0

Sequence Diagram(s)

sequenceDiagram
participant BackupUploader
participant osFile as *os.File
participant errgroup
participant HTTPClient
participant S3Endpoint
BackupUploader->>osFile: Read file size
BackupUploader->>HTTPClient: Retrieve upload URLs with context
BackupUploader->>errgroup: Start bounded upload workers
errgroup->>osFile: Read independent io.SectionReader parts
errgroup->>HTTPClient: Send multipart part request
HTTPClient->>S3Endpoint: Upload part
S3Endpoint-->>HTTPClient: Return response
HTTPClient->>S3Endpoint: Retry HTTP 429 or 5xx response
Loading

Poem

I’m a rabbit with uploads to make,
Many small parts hop in the wake.
Workers bound, retries grow wise,
Drained responses keep connections alive.
One field now tells how many may fly.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check nameStatusExplanation
Docstring Coverage✅ PassedNo functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check✅ PassedCheck skipped because no linked issues were found for this pull request.
Out of Scope Changes check✅ PassedCheck skipped because no linked issues were found for this pull request.
Description Check✅ PassedCheck skipped - CodeRabbit’s high-level summary is enabled.
Title check✅ PassedThe title clearly summarizes the main change: concurrent S3 part uploads.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@barnabehvrd