fix(upload): bound delta-worker connections instead of disabling delta
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) <noreply@anthropic.com>
This commit is contained in:
@@ -13,20 +13,12 @@ import { blake3HexOfFile } from '$lib/vendor/hashWasm';
|
|||||||
|
|
||||||
/** Files smaller than this skip delta: the round-trips cost more than the bytes.
|
/** 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
|
* 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;
|
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_WORKER_URL = '/workers/deltaWorker.js';
|
||||||
const DELTA_TIMEOUT_BASE_MS = 120_000;
|
const DELTA_TIMEOUT_BASE_MS = 120_000;
|
||||||
const DELTA_TIMEOUT_PER_GB_MS = 90_000;
|
const DELTA_TIMEOUT_PER_GB_MS = 90_000;
|
||||||
@@ -72,7 +64,7 @@ export function tryDeltaUpload(
|
|||||||
): Promise<DeltaUploadAnswer | null> {
|
): Promise<DeltaUploadAnswer | null> {
|
||||||
if (
|
if (
|
||||||
!folderId ||
|
!folderId ||
|
||||||
file.size < DELTA_WORKER_MIN_SIZE ||
|
file.size < DELTA_UPLOAD_MIN_SIZE ||
|
||||||
usable === false ||
|
usable === false ||
|
||||||
typeof Worker === 'undefined'
|
typeof Worker === 'undefined'
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ const SLICE_BYTES = 8 * 1024 * 1024;
|
|||||||
const NEGOTIATE_BATCH = 256;
|
const NEGOTIATE_BATCH = 256;
|
||||||
/** Group missing chunks into PUT bodies of at most this many bytes. */
|
/** Group missing chunks into PUT bodies of at most this many bytes. */
|
||||||
const UPLOAD_BATCH_BYTES = 8 * 1024 * 1024;
|
const UPLOAD_BATCH_BYTES = 8 * 1024 * 1024;
|
||||||
/** Concurrent chunk-PUT requests. */
|
/** Concurrent chunk-PUT requests. Kept at 1: several folder files upload through
|
||||||
const UPLOAD_CONCURRENCY = 2;
|
* 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. */
|
/** Re-commit attempts when the server answers 409 still_missing. */
|
||||||
const COMMIT_RETRIES = 2;
|
const COMMIT_RETRIES = 2;
|
||||||
|
|
||||||
@@ -175,37 +179,44 @@ workerScope.onmessage = async (event) => {
|
|||||||
for (let i = 0; i < UPLOAD_CONCURRENCY; i++) uploadWorkers.push(uploadLoop());
|
for (let i = 0; i < UPLOAD_CONCURRENCY; i++) uploadWorkers.push(uploadLoop());
|
||||||
|
|
||||||
// ── Negotiate stage ───────────────────────────────────────────
|
// ── 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<void>[]} */
|
/** @type {Promise<void>[]} */
|
||||||
const negotiations = [];
|
const negotiations = [];
|
||||||
|
let negotiateTail = Promise.resolve();
|
||||||
const negotiate = (/** @type {WorkerChunk[]} */ fresh) => {
|
const negotiate = (/** @type {WorkerChunk[]} */ fresh) => {
|
||||||
if (fresh.length === 0 || failed) return;
|
if (fresh.length === 0 || failed) return;
|
||||||
negotiations.push(
|
const run = negotiateTail.then(async () => {
|
||||||
(async () => {
|
if (failed) return;
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/files/delta/negotiate', {
|
const response = await fetch('/api/files/delta/negotiate', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: mutHeaders,
|
headers: mutHeaders,
|
||||||
body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) })
|
body: JSON.stringify({ chunks: fresh.map(({ h, s }) => ({ h, s })) })
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
failed = failed || `negotiate failed (HTTP ${response.status})`;
|
failed = failed || `negotiate failed (HTTP ${response.status})`;
|
||||||
return;
|
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 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) ────────────────────
|
// ── Chunking stage (drives the other two) ────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user