From 6be3c9958092508f1d7ab85b1d1932db0f4dc6c1 Mon Sep 17 00:00:00 2001 From: DioCrafts Date: Sun, 21 Jun 2026 03:09:45 +0200 Subject: [PATCH] fix(upload): bound delta-worker connections instead of disabling delta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the connection-exhaustion fix. Rather than routing large files to plain uploads (which kept STORAGE dedup but gave up delta's re-upload bandwidth savings), keep delta for every file >= 8 MB and instead cap each worker's concurrent connections so a few large files uploading at once can't blow past the browser's ~6-per-host budget and starve the small-file plain uploads. - deltaWorker.js: serialize negotiate (at most one in flight per worker) and drop chunk-PUT concurrency 2 -> 1, so each worker holds ~2 connections max. - deltaUpload.ts: revert the 64 MB threshold back to 8 MB — every large file gets sub-file dedup again. (Storage dedup was never affected: BLAKE3 + CDC + ref-counting run server-side for plain and delta uploads alike.) With main upload concurrency at 2, total in-flight upload connections stay <= ~4. npm run check: clean, 58 tests. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/lib/api/endpoints/deltaUpload.ts | 18 ++--- frontend/static/workers/deltaWorker.js | 67 +++++++++++-------- 2 files changed, 44 insertions(+), 41 deletions(-) diff --git a/frontend/src/lib/api/endpoints/deltaUpload.ts b/frontend/src/lib/api/endpoints/deltaUpload.ts index 01006653..c7af61cd 100644 --- a/frontend/src/lib/api/endpoints/deltaUpload.ts +++ b/frontend/src/lib/api/endpoints/deltaUpload.ts @@ -13,20 +13,12 @@ import { blake3HexOfFile } from '$lib/vendor/hashWasm'; /** Files smaller than this skip delta: the round-trips cost more than the bytes. * Also the upper bound for client-side whole-file hashing (instant by-hash - * uploads) — we never read a file larger than this fully into memory. */ + * uploads) — we never read a file larger than this fully into memory. Files at + * or above this run the delta worker (sub-file dedup, saves re-upload + * bandwidth); the worker self-bounds its concurrent connections so a few + * running at once can't exhaust the browser's ~6-per-host budget. */ export const DELTA_UPLOAD_MIN_SIZE = 8 * 1024 * 1024; -/** Only files at least this large actually run the delta worker. A delta worker - * opens SEVERAL concurrent requests (overlapping `negotiate` batches + chunk - * PUTs); a few running at once exhaust the browser's ~6 connections-per-host - * budget and starve plain uploads (they queue, then the upload watchdog cancels - * them — the "stuck at N%" folder upload). Typical large files (e.g. tens of MB) - * therefore go through a single-connection plain upload; delta is reserved for - * genuinely huge files, where chunked, resumable transfer earns its keep and few - * run concurrently. (Delta's real payoff — sub-file dedup — only helps on - * re-upload anyway, not the first upload that dominates these batches.) */ -const DELTA_WORKER_MIN_SIZE = 64 * 1024 * 1024; - const DELTA_WORKER_URL = '/workers/deltaWorker.js'; const DELTA_TIMEOUT_BASE_MS = 120_000; const DELTA_TIMEOUT_PER_GB_MS = 90_000; @@ -72,7 +64,7 @@ export function tryDeltaUpload( ): Promise { if ( !folderId || - file.size < DELTA_WORKER_MIN_SIZE || + file.size < DELTA_UPLOAD_MIN_SIZE || usable === false || typeof Worker === 'undefined' ) { diff --git a/frontend/static/workers/deltaWorker.js b/frontend/static/workers/deltaWorker.js index 803194bd..c50bb6d8 100644 --- a/frontend/static/workers/deltaWorker.js +++ b/frontend/static/workers/deltaWorker.js @@ -30,8 +30,12 @@ const SLICE_BYTES = 8 * 1024 * 1024; const NEGOTIATE_BATCH = 256; /** Group missing chunks into PUT bodies of at most this many bytes. */ const UPLOAD_BATCH_BYTES = 8 * 1024 * 1024; -/** Concurrent chunk-PUT requests. */ -const UPLOAD_CONCURRENCY = 2; +/** Concurrent chunk-PUT requests. Kept at 1: several folder files upload through + * their own workers at once, and the browser only grants ~6 connections per + * host. Combined with serialized negotiate (below) each worker holds at most + * ~2 connections (one negotiate + one chunk PUT), so a couple of concurrent + * large files can't starve the plain uploads of the small ones. */ +const UPLOAD_CONCURRENCY = 1; /** Re-commit attempts when the server answers 409 still_missing. */ const COMMIT_RETRIES = 2; @@ -175,37 +179,44 @@ workerScope.onmessage = async (event) => { for (let i = 0; i < UPLOAD_CONCURRENCY; i++) uploadWorkers.push(uploadLoop()); // ── Negotiate stage ─────────────────────────────────────────── + // Serialized: each negotiate awaits the previous one, so at most a single + // negotiate request is ever in flight per worker. Together with the single + // chunk-PUT lane (UPLOAD_CONCURRENCY = 1) this caps the worker at ~2 + // concurrent connections, leaving room under the browser's ~6-per-host + // budget for the other folder files uploading in parallel. /** @type {Promise[]} */ const negotiations = []; + let negotiateTail = Promise.resolve(); const negotiate = (/** @type {WorkerChunk[]} */ fresh) => { if (fresh.length === 0 || failed) return; - negotiations.push( - (async () => { - try { - const response = await fetch('/api/files/delta/negotiate', { - method: 'POST', - headers: mutHeaders, - body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) }) - }); - if (!response.ok) { - failed = failed || `negotiate failed (HTTP ${response.status})`; - return; - } - const missing = new Set(/** @type {{missing: string[]}} */ (await response.json()).missing); - for (const c of fresh) { - if (missing.has(c.h)) { - uploadQueue.push(c); - } else { - reusedBytes += c.s; - } - } - signalUploaders(); - progress(); - } catch (err) { - failed = failed || `negotiate failed: ${err instanceof Error ? err.message : String(err)}`; + const run = negotiateTail.then(async () => { + if (failed) return; + try { + const response = await fetch('/api/files/delta/negotiate', { + method: 'POST', + headers: mutHeaders, + body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) }) + }); + if (!response.ok) { + failed = failed || `negotiate failed (HTTP ${response.status})`; + return; } - })() - ); + const missing = new Set(/** @type {{missing: string[]}} */ (await response.json()).missing); + for (const c of fresh) { + if (missing.has(c.h)) { + uploadQueue.push(c); + } else { + reusedBytes += c.s; + } + } + signalUploaders(); + progress(); + } catch (err) { + failed = failed || `negotiate failed: ${err instanceof Error ? err.message : String(err)}`; + } + }); + negotiateTail = run.catch(() => {}); + negotiations.push(run); }; // ── Chunking stage (drives the other two) ────────────────────