@coderook/cli 0.12.0 → 0.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +71 -2
- package/dist/cli/src/cli.js +167 -10
- package/dist/cli/src/mcp.js +349 -0
- package/dist/cli/src/track_commands.js +157 -0
- package/dist/desktop-app/src/main/compress.js +95 -0
- package/dist/desktop-app/src/main/detect.js +137 -0
- package/dist/desktop-app/src/main/download.js +162 -15
- package/dist/desktop-app/src/main/profile.js +73 -0
- package/dist/desktop-app/src/main/retry.js +96 -0
- package/dist/desktop-app/src/main/solid.js +103 -0
- package/dist/desktop-app/src/main/staging.js +140 -0
- package/dist/desktop-app/src/main/tracks.js +132 -0
- package/dist/desktop-app/src/main/upload.js +1039 -55
- package/dist/desktop-app/src/main/worktree.js +214 -25
- package/package.json +1 -1
|
@@ -20,9 +20,56 @@ const faults_js_1 = require("./faults.js");
|
|
|
20
20
|
const identify_js_1 = require("./identify.js");
|
|
21
21
|
const worktree_js_1 = require("./worktree.js");
|
|
22
22
|
const publish_name_js_1 = require("./publish_name.js");
|
|
23
|
+
const compress_js_1 = require("./compress.js");
|
|
24
|
+
const cbx_js_1 = require("./cbx.js");
|
|
25
|
+
const profile_js_1 = require("./profile.js");
|
|
26
|
+
const solid_js_1 = require("./solid.js");
|
|
27
|
+
const retry_js_1 = require("./retry.js");
|
|
28
|
+
const staging_js_1 = require("./staging.js");
|
|
23
29
|
/** Above this the API insists on a multipart session. */
|
|
24
30
|
const DIRECT_LIMIT = 95 * 1024 * 1024;
|
|
31
|
+
/*
|
|
32
|
+
Above this a file is cut into content-defined chunks instead of being sent
|
|
33
|
+
whole. Below it the round trips cost more than the chunking saves — the
|
|
34
|
+
chunker's own average chunk is eight megabytes, so a smaller file would
|
|
35
|
+
usually come out as one chunk anyway.
|
|
36
|
+
*/
|
|
37
|
+
const CHUNK_THRESHOLD = 16 * 1024 * 1024;
|
|
38
|
+
/*
|
|
39
|
+
How many files are sent at once. Six is the limit browsers settled on per
|
|
40
|
+
host for the same reason: enough to keep the link busy through the latency
|
|
41
|
+
of each request, few enough that a slow service is not buried by a client.
|
|
42
|
+
*/
|
|
43
|
+
const UPLOAD_LANES = 6;
|
|
44
|
+
/*
|
|
45
|
+
Below this a file travels with others in one request rather than alone.
|
|
46
|
+
|
|
47
|
+
Measured rather than guessed, twice. The first number came from a source
|
|
48
|
+
folder where ninety-six percent of files were under sixty-four kilobytes.
|
|
49
|
+
A four and a half gigabyte game then showed the opposite shape: eight
|
|
50
|
+
hundred files of three hundred kilobytes per section, each just above that
|
|
51
|
+
line and so each paying its own round trip.
|
|
52
|
+
|
|
53
|
+
What the game measured is the reason for the number. Every request costs
|
|
54
|
+
about six hundred and thirty milliseconds before any bytes move, and the
|
|
55
|
+
lane carries roughly 0.79 MB/s. So a three hundred kilobyte file spends
|
|
56
|
+
sixty percent of its time on overhead and a seven megabyte file spends
|
|
57
|
+
seven. The line belongs where the overhead stops dominating, which is a few
|
|
58
|
+
megabytes and not a few kilobytes.
|
|
59
|
+
|
|
60
|
+
Four megabytes was the first attempt and it overshot. Measured on the same
|
|
61
|
+
game twice: four hundred kilobyte files went from 1.80 to 3.24 MB/s when
|
|
62
|
+
batched, while 1.3 megabyte files went from 4.43 down to 3.88 — because
|
|
63
|
+
three concurrent batches move less than six concurrent requests once a file
|
|
64
|
+
is large enough that transfer, not overhead, dominates. The crossover is
|
|
65
|
+
around a megabyte.
|
|
66
|
+
*/
|
|
67
|
+
const BATCH_FILE_LIMIT = 1024 * 1024;
|
|
68
|
+
/** How much one batch may carry, and how many objects it may name. */
|
|
69
|
+
const BATCH_BYTES = 24 * 1024 * 1024;
|
|
70
|
+
const BATCH_COUNT = 256;
|
|
25
71
|
const PART_SIZE = 32 * 1024 * 1024;
|
|
72
|
+
/* Retrying lives in retry.ts; the downloader needs the same rule. */
|
|
26
73
|
const MEDIA_TYPES = {
|
|
27
74
|
".css": "text/css",
|
|
28
75
|
".csv": "text/csv",
|
|
@@ -68,6 +115,12 @@ class Uploader {
|
|
|
68
115
|
credentials;
|
|
69
116
|
aborted = false;
|
|
70
117
|
controller = new AbortController();
|
|
118
|
+
/** Null until the service has been asked; see gzipAllowed. */
|
|
119
|
+
gzipSupported = null;
|
|
120
|
+
/** Null until the service has been asked; see chunkingAllowed. */
|
|
121
|
+
chunkingSupported = null;
|
|
122
|
+
/** Null until the service has been asked; see packingAllowed. */
|
|
123
|
+
packingSupported = null;
|
|
71
124
|
constructor(credentials) {
|
|
72
125
|
this.credentials = credentials;
|
|
73
126
|
}
|
|
@@ -79,7 +132,54 @@ class Uploader {
|
|
|
79
132
|
if (this.aborted)
|
|
80
133
|
throw new UploadCancelled();
|
|
81
134
|
}
|
|
135
|
+
/**
|
|
136
|
+
* One request, repeated while repeating it might help.
|
|
137
|
+
*
|
|
138
|
+
* Everything the uploader sends passes through here, which is the only
|
|
139
|
+
* reason a single change can cover all of it. The alternative — retrying at
|
|
140
|
+
* each call site — is where the previous absence of retries came from:
|
|
141
|
+
* every site could see that its own operation was safe to repeat, and no
|
|
142
|
+
* site was responsible for repeating it.
|
|
143
|
+
*/
|
|
82
144
|
async call(route, init) {
|
|
145
|
+
let wait = retry_js_1.RETRY_FIRST_WAIT_MS;
|
|
146
|
+
for (let attempt = 1;; attempt += 1) {
|
|
147
|
+
// Cancellation beats a pending retry: a cancelled upload has to stop
|
|
148
|
+
// waiting rather than serve out its backoff first.
|
|
149
|
+
this.check();
|
|
150
|
+
try {
|
|
151
|
+
return await this.attempt(route, init);
|
|
152
|
+
}
|
|
153
|
+
catch (error) {
|
|
154
|
+
// Its own cancellation first: `worthRetrying` knows nothing of it.
|
|
155
|
+
if (error instanceof UploadCancelled)
|
|
156
|
+
throw error;
|
|
157
|
+
if (attempt >= retry_js_1.RETRY_ATTEMPTS || !(0, retry_js_1.worthRetrying)(error))
|
|
158
|
+
throw error;
|
|
159
|
+
await this.pause(wait);
|
|
160
|
+
wait *= 2;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Wait, unless the upload is cancelled while waiting.
|
|
166
|
+
*
|
|
167
|
+
* Jittered, because the lanes fail together. Six requests against a gateway
|
|
168
|
+
* that has just gone away all fail within milliseconds of each other, and
|
|
169
|
+
* six retries timed from those failures would arrive together too — which
|
|
170
|
+
* turns one outage into a second one at precisely the wrong moment.
|
|
171
|
+
*/
|
|
172
|
+
async pause(ms) {
|
|
173
|
+
try {
|
|
174
|
+
await (0, retry_js_1.pauseFor)(this.controller.signal, ms);
|
|
175
|
+
}
|
|
176
|
+
catch {
|
|
177
|
+
// The signal aborted mid-wait; say so in this side's own terms.
|
|
178
|
+
throw new UploadCancelled();
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
/** A single attempt. Everything about repeating it lives in `call`. */
|
|
182
|
+
async attempt(route, init) {
|
|
83
183
|
const token = await this.credentials.token();
|
|
84
184
|
if (!token)
|
|
85
185
|
throw new Error("Sign in again before uploading");
|
|
@@ -96,28 +196,72 @@ class Uploader {
|
|
|
96
196
|
signal: this.controller.signal,
|
|
97
197
|
});
|
|
98
198
|
const text = await response.text();
|
|
99
|
-
const body = text ? JSON.parse(text) : {};
|
|
100
199
|
if (!response.ok) {
|
|
101
|
-
|
|
200
|
+
/*
|
|
201
|
+
Parsed only when it looks like JSON. An edge failure serves an HTML
|
|
202
|
+
page, and parsing that unconditionally raised `Unexpected token '<'`
|
|
203
|
+
— which names the parser rather than the 502 behind it, and sends you
|
|
204
|
+
looking in entirely the wrong place.
|
|
205
|
+
*/
|
|
206
|
+
let body = {};
|
|
207
|
+
if (text.trimStart().startsWith("{")) {
|
|
208
|
+
try {
|
|
209
|
+
body = JSON.parse(text);
|
|
210
|
+
}
|
|
211
|
+
catch {
|
|
212
|
+
body = {};
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const message = body.error?.message ?? `${route} failed (${response.status})`;
|
|
102
216
|
/*
|
|
103
217
|
The service says what kind of failure this is, and callers need that
|
|
104
218
|
to say anything useful — "someone else saved first" deserves a
|
|
105
|
-
different suggestion from "the disk is full". Carrying the code on
|
|
106
|
-
|
|
219
|
+
different suggestion from "the disk is full". Carrying the code on the
|
|
220
|
+
error keeps them from matching on wording, and is what lets
|
|
221
|
+
`worthRetrying` tell a refusal apart from a hiccup.
|
|
107
222
|
*/
|
|
108
223
|
throw Object.assign(new Error(message), {
|
|
109
|
-
code: typeof body
|
|
224
|
+
code: typeof body.error?.code === "string" ? body.error.code : "",
|
|
110
225
|
status: response.status,
|
|
111
226
|
});
|
|
112
227
|
}
|
|
113
|
-
|
|
228
|
+
if (!text)
|
|
229
|
+
return {};
|
|
230
|
+
try {
|
|
231
|
+
return JSON.parse(text);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/*
|
|
235
|
+
A 200 carrying something that is not JSON is not an answer — it is
|
|
236
|
+
almost always an edge page served in place of the service. Thrown
|
|
237
|
+
without a status so it is treated as a connection that produced
|
|
238
|
+
nothing, which is what it is.
|
|
239
|
+
*/
|
|
240
|
+
throw new Error(`${route} returned a malformed reply`);
|
|
241
|
+
}
|
|
114
242
|
}
|
|
115
243
|
async run(request, report) {
|
|
116
244
|
// A version is a snapshot, not a delta, so it has to name every file in
|
|
117
245
|
// the project — not merely the ones being sent this time. Anything
|
|
118
246
|
// unchanged keeps the object the previous version already pointed at.
|
|
119
247
|
const rules = await (0, worktree_js_1.readRules)(request.localPath);
|
|
120
|
-
|
|
248
|
+
/*
|
|
249
|
+
Surveyed, not listed.
|
|
250
|
+
|
|
251
|
+
`changedFiles` stops after twenty thousand rows, which is right for
|
|
252
|
+
something a person scrolls through and was also deciding what got
|
|
253
|
+
uploaded — so a project past that many files was silently backed up in
|
|
254
|
+
part, with nothing anywhere saying the rest existed. The survey has no
|
|
255
|
+
cap and reads nothing but sizes, which is what makes running it over
|
|
256
|
+
everything affordable.
|
|
257
|
+
|
|
258
|
+
`onDiskNow` in particular has to be complete: it is what tells a file
|
|
259
|
+
that is still here from one this workspace deliberately removed, and a
|
|
260
|
+
truncated version of it would propose deleting everything the walk did
|
|
261
|
+
not reach.
|
|
262
|
+
*/
|
|
263
|
+
const everything = await (0, profile_js_1.timed)("survey the project", () => (0, worktree_js_1.surveyFiles)(request.localPath, rules));
|
|
264
|
+
(0, profile_js_1.counted)("files surveyed", everything.length);
|
|
121
265
|
const ticked = new Set(request.include);
|
|
122
266
|
if (!ticked.size)
|
|
123
267
|
throw new Error("Nothing is selected to upload");
|
|
@@ -194,11 +338,32 @@ class Uploader {
|
|
|
194
338
|
percent: Math.round((index / sending.length) * 20),
|
|
195
339
|
bytesPerSecond: 0,
|
|
196
340
|
});
|
|
341
|
+
/*
|
|
342
|
+
Hashing is the first thing that opens the file, and opening is what
|
|
343
|
+
fails on a file another program holds — a database in use, a browser
|
|
344
|
+
profile's LOCK, an antivirus mid-scan. On Windows those still answer
|
|
345
|
+
stat() perfectly well, so the check three lines above lets them
|
|
346
|
+
through and the read below was the one that actually broke.
|
|
347
|
+
|
|
348
|
+
It was unguarded, so the person got a raw "EBUSY: resource busy or
|
|
349
|
+
locked" naming a path they never chose to think about, instead of the
|
|
350
|
+
sentence this code already writes for exactly this situation. The
|
|
351
|
+
outcome was always going to be a refusal; what was missing was it
|
|
352
|
+
being legible.
|
|
353
|
+
*/
|
|
354
|
+
let digest;
|
|
355
|
+
try {
|
|
356
|
+
digest = await (0, profile_js_1.timed)("hash files", () => digestOf(full));
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
vanished.push(file.path);
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
197
362
|
declarations.push({
|
|
198
363
|
path: file.path,
|
|
199
364
|
full,
|
|
200
365
|
size,
|
|
201
|
-
sha256:
|
|
366
|
+
sha256: digest,
|
|
202
367
|
mediaType: mediaTypeOf(file.path),
|
|
203
368
|
});
|
|
204
369
|
totalBytes += size;
|
|
@@ -237,70 +402,430 @@ class Uploader {
|
|
|
237
402
|
repositoryId = existing;
|
|
238
403
|
}
|
|
239
404
|
}
|
|
240
|
-
|
|
405
|
+
/*
|
|
406
|
+
Sent in sections, not in one attempt.
|
|
407
|
+
|
|
408
|
+
A section is a bounded amount of work — a few hundred megabytes, or a few
|
|
409
|
+
thousand files, whichever comes first — and each one finishes before the
|
|
410
|
+
next begins. A small project is a single section and behaves exactly as
|
|
411
|
+
it did; a thirty gigabyte one is many, and holds no more at a time than
|
|
412
|
+
the small one does.
|
|
413
|
+
|
|
414
|
+
The sections are planned from the survey rather than discovered as the
|
|
415
|
+
upload goes, so the size and shape of the work are known before a byte
|
|
416
|
+
moves, and a resumed upload plans the same sections in the same order.
|
|
417
|
+
*/
|
|
418
|
+
const allDeclarations = declarations;
|
|
419
|
+
const declarationByPath = new Map(allDeclarations.map((one) => [one.path, one]));
|
|
420
|
+
const sections = (0, staging_js_1.planSections)(allDeclarations.map((one) => ({ path: one.path, size: one.size })));
|
|
421
|
+
(0, profile_js_1.counted)("sections planned", sections.length);
|
|
241
422
|
const uploaded = [];
|
|
242
423
|
let sentBytes = 0;
|
|
424
|
+
/*
|
|
425
|
+
Bytes that actually travelled, which is not the same as bytes accounted
|
|
426
|
+
for: a file the service already held advances the progress bar and costs
|
|
427
|
+
nothing. Reporting the rate from the second number makes a fast, mostly
|
|
428
|
+
reused save look like a stalled one.
|
|
429
|
+
*/
|
|
430
|
+
let transferred = 0;
|
|
243
431
|
/*
|
|
244
432
|
Files whose content the service already had. Counted apart from what was
|
|
245
433
|
sent, because reporting them as sent would overstate the work and hide
|
|
246
434
|
the thing worth knowing: an interrupted save costs nothing to finish.
|
|
247
435
|
*/
|
|
248
436
|
let alreadyOnAccount = 0;
|
|
249
|
-
|
|
437
|
+
/*
|
|
438
|
+
How far each lane is through the file it currently holds.
|
|
439
|
+
|
|
440
|
+
With one worker, "everything finished plus how far I am" was simply
|
|
441
|
+
true. With six it is not: each lane added its own offset to the same
|
|
442
|
+
shared total, so the reported figure jumped between six different
|
|
443
|
+
answers and the progress bar ran backwards. Keeping the offsets apart
|
|
444
|
+
and summing them makes the total the sum of real work again.
|
|
445
|
+
*/
|
|
446
|
+
const inFlight = new Map();
|
|
447
|
+
const progressBytes = () => {
|
|
448
|
+
let total = sentBytes;
|
|
449
|
+
for (const offset of inFlight.values())
|
|
450
|
+
total += offset;
|
|
451
|
+
return total;
|
|
452
|
+
};
|
|
453
|
+
/*
|
|
454
|
+
How fast the thing on screen is moving.
|
|
455
|
+
|
|
456
|
+
Two faults, and they compounded. The rate was `transferred` over the
|
|
457
|
+
whole elapsed time, while the bar beside it showed `progressBytes()` —
|
|
458
|
+
different counters measuring different things. `transferred` only moves
|
|
459
|
+
when a file *finishes*, so a single large file reported a rate of
|
|
460
|
+
roughly nothing for its entire transfer and then jumped at the end: one
|
|
461
|
+
real upload sat at "152 MB of 1.1 GB · 10 B/s", which is not a slow
|
|
462
|
+
upload, it is a number describing something else.
|
|
463
|
+
|
|
464
|
+
And dividing by the time since the start made it a lifetime average, so
|
|
465
|
+
a slow first minute went on dragging the figure down long after the
|
|
466
|
+
link had recovered — the opposite of what somebody watching it wants to
|
|
467
|
+
know, which is whether it is moving *now*.
|
|
468
|
+
|
|
469
|
+
So: the same counter the bar uses, over a short trailing window.
|
|
470
|
+
*/
|
|
471
|
+
const RATE_WINDOW_MS = 5000;
|
|
472
|
+
const samples = [];
|
|
250
473
|
const rate = () => {
|
|
251
|
-
const
|
|
252
|
-
|
|
474
|
+
const now = Date.now();
|
|
475
|
+
const bytes = progressBytes();
|
|
476
|
+
const last = samples[samples.length - 1];
|
|
477
|
+
// Sampled rather than recorded per call: this is asked once per report,
|
|
478
|
+
// and reports arrive per file, which for small files is thousands a
|
|
479
|
+
// second.
|
|
480
|
+
if (!last || now - last.at >= 250)
|
|
481
|
+
samples.push({ at: now, bytes });
|
|
482
|
+
while (samples.length > 2 && now - samples[0].at > RATE_WINDOW_MS) {
|
|
483
|
+
samples.shift();
|
|
484
|
+
}
|
|
485
|
+
const oldest = samples[0];
|
|
486
|
+
const seconds = (now - oldest.at) / 1000;
|
|
487
|
+
/*
|
|
488
|
+
Nothing until there is something to divide. Answering zero keeps the
|
|
489
|
+
window quiet rather than showing a figure computed from a fraction of
|
|
490
|
+
a second, which reads as a stall on an upload that has barely begun.
|
|
491
|
+
*/
|
|
492
|
+
if (seconds < 0.5)
|
|
493
|
+
return 0;
|
|
494
|
+
return Math.max(0, Math.round((bytes - oldest.bytes) / seconds));
|
|
253
495
|
};
|
|
254
|
-
|
|
255
|
-
|
|
496
|
+
/*
|
|
497
|
+
Two pipelines, not one queue.
|
|
498
|
+
|
|
499
|
+
The sections were run one after another, and they do not compete for the
|
|
500
|
+
same thing: a section of small files spends about three seconds per batch
|
|
501
|
+
waiting on the service — measured, and independent of how many objects
|
|
502
|
+
the batch holds — while a section of large files is limited by the link.
|
|
503
|
+
One leaves bandwidth idle; the other cannot use any more.
|
|
504
|
+
|
|
505
|
+
So they run together. Each pipeline takes the next section of its own
|
|
506
|
+
kind, and when one runs out it stops rather than stealing from the other,
|
|
507
|
+
because that would put two bandwidth-bound pipelines against a link that
|
|
508
|
+
is already saturated by one.
|
|
509
|
+
*/
|
|
510
|
+
/*
|
|
511
|
+
Classified by where the bytes are, not by whether every file qualifies.
|
|
512
|
+
|
|
513
|
+
Requiring every file to be small put one large file in charge of a whole
|
|
514
|
+
section: on a four gigabyte game it left three sections in one pipeline
|
|
515
|
+
and eighteen in the other, which is not two pipelines. Judging by the
|
|
516
|
+
share of bytes that will be batched splits the same game nine and twelve.
|
|
517
|
+
*/
|
|
518
|
+
const smallShare = (one) => one.files
|
|
519
|
+
.filter((file) => file.size <= BATCH_FILE_LIMIT)
|
|
520
|
+
.reduce((sum, file) => sum + file.size, 0) / Math.max(one.bytes, 1);
|
|
521
|
+
const batchy = sections.filter((one) => smallShare(one) >= 0.5);
|
|
522
|
+
const heavy = sections.filter((one) => smallShare(one) < 0.5);
|
|
523
|
+
(0, profile_js_1.counted)("sections batched", batchy.length);
|
|
524
|
+
(0, profile_js_1.counted)("sections heavy", heavy.length);
|
|
525
|
+
const runSection = async (section) => {
|
|
526
|
+
const declarations = section.files.map((file) => declarationByPath.get(file.path));
|
|
256
527
|
report({
|
|
257
528
|
stage: "upload",
|
|
258
|
-
files:
|
|
259
|
-
totalFiles:
|
|
260
|
-
bytes:
|
|
529
|
+
files: 0,
|
|
530
|
+
totalFiles: allDeclarations.length,
|
|
531
|
+
bytes: progressBytes(),
|
|
261
532
|
totalBytes,
|
|
262
|
-
path:
|
|
263
|
-
percent: 20 + Math.round((
|
|
533
|
+
path: `Section ${section.index + 1} of ${sections.length}`,
|
|
534
|
+
percent: 20 + Math.round((progressBytes() / Math.max(totalBytes, 1)) * 72),
|
|
264
535
|
bytesPerSecond: rate(),
|
|
265
536
|
});
|
|
266
537
|
/*
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
538
|
+
Ask once what the account already holds.
|
|
539
|
+
|
|
540
|
+
This used to be one request per file, immediately before deciding
|
|
541
|
+
whether to send it — nine thousand files meant nine thousand serial
|
|
542
|
+
round trips before a single byte moved, and on a folder of that size the
|
|
543
|
+
asking took longer than the transfer. The answer per digest is a boolean
|
|
544
|
+
and an identifier, so the whole question fits in one request.
|
|
545
|
+
*/
|
|
546
|
+
const alreadyHeld = await (0, profile_js_1.timed)("ask what is already held", () => this.storedInBulk(repositoryId, declarations.map((one) => one.sha256)));
|
|
547
|
+
/*
|
|
548
|
+
The small files go first, packed together.
|
|
549
|
+
|
|
550
|
+
They are the overwhelming majority by count and almost nothing by size,
|
|
551
|
+
so sending them one per request is where an upload spends its time. What
|
|
552
|
+
is left after this — the large files — is where bandwidth actually
|
|
553
|
+
matters, and those still go one per request through the lanes below.
|
|
554
|
+
*/
|
|
555
|
+
const missing = declarations.filter((one) => !alreadyHeld.has(one.sha256));
|
|
556
|
+
const smallOnes = missing.filter((one) => one.size <= BATCH_FILE_LIMIT);
|
|
557
|
+
// 3. Send the objects.
|
|
558
|
+
/*
|
|
559
|
+
What was sent, in whichever shape it was sent. A small file is one
|
|
560
|
+
object; a large one is an ordered list of chunks and the digest of the
|
|
561
|
+
whole, which is what gives it an identity when no single object holds it.
|
|
271
562
|
*/
|
|
272
|
-
const
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
563
|
+
const batched = new Map();
|
|
564
|
+
/*
|
|
565
|
+
Files that went up inside a solid pack. Kept apart from `batched` because
|
|
566
|
+
they are named differently in the version: a slice of a pack rather than
|
|
567
|
+
an object of their own.
|
|
568
|
+
*/
|
|
569
|
+
const packedInto = new Map();
|
|
570
|
+
if (smallOnes.length > 1) {
|
|
571
|
+
let pack = [];
|
|
572
|
+
let packBytes = 0;
|
|
573
|
+
/*
|
|
574
|
+
Batches overlap rather than queue.
|
|
575
|
+
|
|
576
|
+
Each one was sent and awaited before the next was built, so the link
|
|
577
|
+
sat idle while the client read and packed the following batch, and
|
|
578
|
+
again while the service stored it. Measured on four hundred kilobyte
|
|
579
|
+
files, one batch at a time managed 2.52 MB/s and four at once 3.26 —
|
|
580
|
+
against a line that can do 5.58.
|
|
581
|
+
|
|
582
|
+
Bounded low: a batch holds its whole body in memory on both ends, so
|
|
583
|
+
three in flight of up to twenty four megabytes is already the most
|
|
584
|
+
that is reasonable to have outstanding.
|
|
585
|
+
*/
|
|
586
|
+
const BATCH_LANES = 3;
|
|
587
|
+
const flying = new Set();
|
|
588
|
+
const flush = async () => {
|
|
589
|
+
if (!pack.length)
|
|
590
|
+
return;
|
|
591
|
+
const taking = pack;
|
|
592
|
+
pack = [];
|
|
593
|
+
packBytes = 0;
|
|
594
|
+
const task = (async () => {
|
|
595
|
+
try {
|
|
596
|
+
/*
|
|
597
|
+
Compressed together first. This is what the .cbx format is for and
|
|
598
|
+
what the upload path was not using: measured on a real project,
|
|
599
|
+
small files came to 42.9% of their size compressed one at a time
|
|
600
|
+
and 30.2% compressed together, because a two-kilobyte file gives a
|
|
601
|
+
compressor no dictionary worth having.
|
|
602
|
+
*/
|
|
603
|
+
const placed = (await this.packingAllowed())
|
|
604
|
+
? await (0, profile_js_1.timed)("send packs", () => this.putPack(repositoryId, taking.map((item) => ({
|
|
605
|
+
declaration: item.declaration,
|
|
606
|
+
body: item.body,
|
|
607
|
+
}))))
|
|
608
|
+
: null;
|
|
609
|
+
if (placed) {
|
|
610
|
+
(0, profile_js_1.counted)("files sent in packs", taking.length);
|
|
611
|
+
for (const [digest, one] of placed)
|
|
612
|
+
packedInto.set(digest, one);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
/*
|
|
616
|
+
Not worth packing — already-compressed content, or too few files.
|
|
617
|
+
They still travel together, just as separate objects.
|
|
618
|
+
*/
|
|
619
|
+
const answer = await (0, profile_js_1.timed)("send batches", () => this.putBatch(repositoryId, taking));
|
|
620
|
+
(0, profile_js_1.counted)("files sent in batches", taking.length);
|
|
621
|
+
for (const [digest, one] of answer)
|
|
622
|
+
batched.set(digest, one);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
/*
|
|
627
|
+
A refusal here is never fatal: everything in the pack is simply
|
|
628
|
+
left for the ordinary path below, which sends it one at a time.
|
|
629
|
+
An older deployment without this route lands here every time.
|
|
630
|
+
*/
|
|
631
|
+
}
|
|
632
|
+
/*
|
|
633
|
+
Only what actually landed. Counting the whole pack meant a batch the
|
|
634
|
+
service refused was counted here and then sent again by the lanes
|
|
635
|
+
below and counted a second time — which is how the progress bar came
|
|
636
|
+
to report a hundred and twenty percent of a forty megabyte upload.
|
|
637
|
+
*/
|
|
638
|
+
for (const item of taking) {
|
|
639
|
+
const digest = item.declaration.sha256;
|
|
640
|
+
if (!batched.has(digest) && !packedInto.has(digest))
|
|
641
|
+
continue;
|
|
642
|
+
sentBytes += item.declaration.size;
|
|
643
|
+
transferred += item.encoded.body.byteLength;
|
|
644
|
+
}
|
|
283
645
|
report({
|
|
284
646
|
stage: "upload",
|
|
285
|
-
files:
|
|
647
|
+
files: 0,
|
|
286
648
|
totalFiles: declarations.length,
|
|
287
|
-
bytes:
|
|
649
|
+
bytes: progressBytes(),
|
|
288
650
|
totalBytes,
|
|
289
|
-
path: declaration.path,
|
|
290
|
-
percent: 20 +
|
|
291
|
-
Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
|
|
651
|
+
path: taking[taking.length - 1].declaration.path,
|
|
652
|
+
percent: 20 + Math.round((progressBytes() / Math.max(totalBytes, 1)) * 72),
|
|
292
653
|
bytesPerSecond: rate(),
|
|
293
654
|
});
|
|
294
|
-
}));
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
655
|
+
})().finally(() => flying.delete(task));
|
|
656
|
+
flying.add(task);
|
|
657
|
+
if (flying.size >= BATCH_LANES)
|
|
658
|
+
await Promise.race(flying);
|
|
659
|
+
};
|
|
660
|
+
for (const declaration of smallOnes) {
|
|
661
|
+
this.check();
|
|
662
|
+
let body;
|
|
663
|
+
try {
|
|
664
|
+
body = await (0, profile_js_1.timed)("read small files", async () => new Uint8Array(await (0, promises_1.readFile)(declaration.full)));
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
/* Unreadable now; the ordinary path reports it properly. */
|
|
668
|
+
continue;
|
|
669
|
+
}
|
|
670
|
+
const allowGzip = await this.gzipAllowed();
|
|
671
|
+
const encoded = await (0, profile_js_1.timed)("compress small files", () => (0, compress_js_1.encodeForUpload)(body, declaration.path, allowGzip));
|
|
672
|
+
if (pack.length >= BATCH_COUNT ||
|
|
673
|
+
packBytes + encoded.body.byteLength > BATCH_BYTES) {
|
|
674
|
+
await flush();
|
|
675
|
+
}
|
|
676
|
+
pack.push({ declaration, body, encoded });
|
|
677
|
+
packBytes += encoded.body.byteLength;
|
|
678
|
+
}
|
|
679
|
+
await flush();
|
|
680
|
+
await Promise.all(flying);
|
|
681
|
+
}
|
|
682
|
+
/*
|
|
683
|
+
Several files at once, rather than one at a time.
|
|
684
|
+
|
|
685
|
+
Every file costs at least one round trip, and they were taken strictly
|
|
686
|
+
in order — so the transfer ran at one file per latency, no matter how
|
|
687
|
+
much bandwidth was free. On a folder of thousands of small files that is
|
|
688
|
+
the whole cost of the upload: the network is idle between each one.
|
|
689
|
+
|
|
690
|
+
Bounded, because the opposite mistake is worse. Thousands of concurrent
|
|
691
|
+
requests would exhaust sockets, defeat the service's own rate limits and
|
|
692
|
+
make a failure impossible to attribute.
|
|
693
|
+
*/
|
|
694
|
+
const queue = declarations.entries();
|
|
695
|
+
const runWorker = async () => {
|
|
696
|
+
for (const [index, declaration] of queue) {
|
|
697
|
+
this.check();
|
|
698
|
+
report({
|
|
699
|
+
stage: "upload",
|
|
700
|
+
files: index,
|
|
701
|
+
totalFiles: declarations.length,
|
|
702
|
+
bytes: sentBytes,
|
|
703
|
+
totalBytes,
|
|
704
|
+
path: declaration.path,
|
|
705
|
+
percent: 20 + Math.round((sentBytes / Math.max(totalBytes, 1)) * 72),
|
|
706
|
+
bytesPerSecond: rate(),
|
|
707
|
+
});
|
|
708
|
+
/*
|
|
709
|
+
Content the service already holds does not need sending. Asking costs
|
|
710
|
+
one round trip and saves the whole file — which after an interrupted
|
|
711
|
+
save is the difference between re-uploading a project and re-uploading
|
|
712
|
+
nothing.
|
|
713
|
+
*/
|
|
714
|
+
/*
|
|
715
|
+
A large file goes up in pieces, cut where its own content says.
|
|
716
|
+
|
|
717
|
+
This is what CBX is for and what it was never doing on the wire. The
|
|
718
|
+
boundaries come from the same chunker the .cbx bundle uses, so an
|
|
719
|
+
edit in the middle of a large file leaves the chunks either side of it
|
|
720
|
+
untouched — and an untouched chunk is one the service already holds,
|
|
721
|
+
which costs a question rather than a transfer. A small file is left
|
|
722
|
+
whole: cutting it up would trade one request for several and save
|
|
723
|
+
nothing.
|
|
724
|
+
*/
|
|
725
|
+
if (declaration.size > CHUNK_THRESHOLD && (await this.chunkingAllowed())) {
|
|
726
|
+
const pieces = await this.putChunked(repositoryId, declaration, (offset) => {
|
|
727
|
+
inFlight.set(index, offset);
|
|
728
|
+
report({
|
|
729
|
+
stage: "upload",
|
|
730
|
+
files: index,
|
|
731
|
+
totalFiles: declarations.length,
|
|
732
|
+
bytes: progressBytes(),
|
|
733
|
+
totalBytes,
|
|
734
|
+
path: declaration.path,
|
|
735
|
+
percent: 20 +
|
|
736
|
+
Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
|
|
737
|
+
bytesPerSecond: rate(),
|
|
738
|
+
});
|
|
739
|
+
});
|
|
740
|
+
inFlight.delete(index);
|
|
741
|
+
alreadyOnAccount += pieces.reused;
|
|
742
|
+
uploaded.push({
|
|
743
|
+
path: declaration.path,
|
|
744
|
+
sha256: declaration.sha256,
|
|
745
|
+
chunks: pieces.chunks,
|
|
746
|
+
sourceSize: declaration.size,
|
|
747
|
+
storedSize: pieces.chunks.reduce((sum, one) => sum + one.storedSize, 0),
|
|
748
|
+
mediaType: declaration.mediaType,
|
|
749
|
+
});
|
|
750
|
+
sentBytes += declaration.size;
|
|
751
|
+
transferred += pieces.sentBytes;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
const fromPack = packedInto.get(declaration.sha256);
|
|
755
|
+
if (fromPack) {
|
|
756
|
+
uploaded.push({
|
|
757
|
+
path: declaration.path,
|
|
758
|
+
sha256: declaration.sha256,
|
|
759
|
+
pack: fromPack,
|
|
760
|
+
sourceSize: declaration.size,
|
|
761
|
+
/* Its share of the pack, so the version's totals stay honest. */
|
|
762
|
+
storedSize: fromPack.storedSize,
|
|
763
|
+
mediaType: declaration.mediaType,
|
|
764
|
+
});
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const fromBatch = batched.get(declaration.sha256);
|
|
768
|
+
if (fromBatch) {
|
|
769
|
+
uploaded.push({
|
|
770
|
+
path: declaration.path,
|
|
771
|
+
objectId: fromBatch.objectId,
|
|
772
|
+
sourceSize: declaration.size,
|
|
773
|
+
storedSize: fromBatch.size,
|
|
774
|
+
mediaType: declaration.mediaType,
|
|
775
|
+
});
|
|
776
|
+
continue;
|
|
777
|
+
}
|
|
778
|
+
const alreadyStored = alreadyHeld.get(declaration.sha256) ??
|
|
779
|
+
/*
|
|
780
|
+
Only asked individually when the batch did not cover it — a digest
|
|
781
|
+
that changed after the bulk question, because the file was rewritten
|
|
782
|
+
while this upload was running.
|
|
783
|
+
*/
|
|
784
|
+
(await this.findStored(repositoryId, declaration));
|
|
785
|
+
if (alreadyStored)
|
|
786
|
+
alreadyOnAccount += 1;
|
|
787
|
+
const result = alreadyStored ??
|
|
788
|
+
(declaration.size <= DIRECT_LIMIT
|
|
789
|
+
? await (0, profile_js_1.timed)("send files one at a time", () => this.putDirect(repositoryId, declaration)).catch((error) => {
|
|
790
|
+
// Naming the file turns "it failed" into something actionable.
|
|
791
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
792
|
+
throw new Error(`${declaration.path}: ${reason}`);
|
|
793
|
+
})
|
|
794
|
+
: await this.putMultipart(repositoryId, declaration, (offset) => {
|
|
795
|
+
report({
|
|
796
|
+
stage: "upload",
|
|
797
|
+
files: index,
|
|
798
|
+
totalFiles: declarations.length,
|
|
799
|
+
bytes: progressBytes(),
|
|
800
|
+
totalBytes,
|
|
801
|
+
path: declaration.path,
|
|
802
|
+
percent: 20 +
|
|
803
|
+
Math.round(((sentBytes + offset) / Math.max(totalBytes, 1)) * 72),
|
|
804
|
+
bytesPerSecond: rate(),
|
|
805
|
+
});
|
|
806
|
+
}));
|
|
807
|
+
inFlight.delete(index);
|
|
808
|
+
uploaded.push({
|
|
809
|
+
path: declaration.path,
|
|
810
|
+
objectId: result.objectId,
|
|
811
|
+
sourceSize: declaration.size,
|
|
812
|
+
storedSize: result.size,
|
|
813
|
+
mediaType: declaration.mediaType,
|
|
814
|
+
});
|
|
815
|
+
sentBytes += declaration.size;
|
|
816
|
+
if (!alreadyStored)
|
|
817
|
+
transferred += declaration.size;
|
|
818
|
+
}
|
|
819
|
+
};
|
|
820
|
+
await Promise.all(Array.from({ length: Math.min(UPLOAD_LANES, declarations.length) }, () => runWorker()));
|
|
821
|
+
};
|
|
822
|
+
const pipeline = async (queue) => {
|
|
823
|
+
for (const section of queue) {
|
|
824
|
+
this.check();
|
|
825
|
+
await runSection(section);
|
|
826
|
+
}
|
|
827
|
+
};
|
|
828
|
+
await Promise.all([pipeline(batchy), pipeline(heavy)]);
|
|
304
829
|
// 4. Name the version, which is what makes the upload visible.
|
|
305
830
|
this.check();
|
|
306
831
|
report({
|
|
@@ -319,9 +844,38 @@ class Uploader {
|
|
|
319
844
|
...uploaded,
|
|
320
845
|
...reused.map((file) => {
|
|
321
846
|
const held = prior.get(file.path);
|
|
847
|
+
/*
|
|
848
|
+
Republished in the shape it was already stored in. A file kept in
|
|
849
|
+
pieces names the same pieces; flattening it to one object here would
|
|
850
|
+
name an object that was never created.
|
|
851
|
+
*/
|
|
322
852
|
return {
|
|
323
853
|
path: file.path,
|
|
324
|
-
|
|
854
|
+
/*
|
|
855
|
+
Republished in the shape it is stored in — all three of them. The
|
|
856
|
+
packed case was missing, so an unchanged small file was declared
|
|
857
|
+
with an empty object id instead of the slice it actually is.
|
|
858
|
+
*/
|
|
859
|
+
...(held.pack
|
|
860
|
+
? {
|
|
861
|
+
/*
|
|
862
|
+
The uploader's own shape for a pack slice, which is what
|
|
863
|
+
the publish step converts to the wire. A file that came
|
|
864
|
+
back from the service is described the same way as one this
|
|
865
|
+
run packed, so the two paths converge before publishing
|
|
866
|
+
rather than at it.
|
|
867
|
+
*/
|
|
868
|
+
pack: {
|
|
869
|
+
packObjectId: held.pack.objectId,
|
|
870
|
+
offset: held.pack.offset,
|
|
871
|
+
length: held.pack.length,
|
|
872
|
+
storedSize: held.storedSize,
|
|
873
|
+
},
|
|
874
|
+
sha256: held.sha256,
|
|
875
|
+
}
|
|
876
|
+
: held.chunks?.length
|
|
877
|
+
? { chunks: held.chunks, sha256: held.sha256 }
|
|
878
|
+
: { objectId: held.objectId }),
|
|
325
879
|
sourceSize: held.sourceSize,
|
|
326
880
|
storedSize: held.storedSize,
|
|
327
881
|
mediaType: held.mediaType,
|
|
@@ -339,8 +893,18 @@ class Uploader {
|
|
|
339
893
|
if (dropped.length) {
|
|
340
894
|
const shown = dropped.slice(0, 5).join(", ");
|
|
341
895
|
const rest = dropped.length > 5 ? ` and ${dropped.length - 5} more` : "";
|
|
896
|
+
/*
|
|
897
|
+
Say what to do about it. Refusing is right — a version quietly missing
|
|
898
|
+
a file somebody chose is the one failure a backup tool must never have
|
|
899
|
+
— but the message stopped at the refusal, and the person is left with
|
|
900
|
+
a list of paths and no idea that the fix is either closing whatever
|
|
901
|
+
holds them or unticking them. Almost every case is a file another
|
|
902
|
+
program has open, so that is what it says.
|
|
903
|
+
*/
|
|
342
904
|
throw new Error(`${dropped.length} selected file${dropped.length === 1 ? "" : "s"} could not be read, ` +
|
|
343
|
-
`so nothing was saved: ${shown}${rest}.
|
|
905
|
+
`so nothing was saved: ${shown}${rest}. This usually means another program ` +
|
|
906
|
+
`has them open. Close it and try again, or untick them in the file list. ` +
|
|
907
|
+
`Nothing was changed on your account.`);
|
|
344
908
|
}
|
|
345
909
|
const sourceBytes = contents.reduce((total, item) => total + item.sourceSize, 0);
|
|
346
910
|
const storedBytes = contents.reduce((total, item) => total + item.storedSize, 0);
|
|
@@ -358,6 +922,7 @@ class Uploader {
|
|
|
358
922
|
...(request.expectedHeadVersionId === undefined
|
|
359
923
|
? {}
|
|
360
924
|
: { expectedHeadVersionId: request.expectedHeadVersionId }),
|
|
925
|
+
...(request.track ? { track: request.track } : {}),
|
|
361
926
|
/*
|
|
362
927
|
Names this attempt so a retry after a lost connection is answered
|
|
363
928
|
with the version already made, rather than making a second one.
|
|
@@ -366,13 +931,36 @@ class Uploader {
|
|
|
366
931
|
repositoryId,
|
|
367
932
|
expectedHeadVersionId: request.expectedHeadVersionId,
|
|
368
933
|
message: request.message,
|
|
369
|
-
|
|
934
|
+
/*
|
|
935
|
+
Named by what each file is. A whole file is its object; a chunked
|
|
936
|
+
one is the digest of its contents — so retrying the same publish
|
|
937
|
+
produces the same key either way, which is the point of it.
|
|
938
|
+
*/
|
|
939
|
+
files: contents.map((item) => ({
|
|
940
|
+
path: item.path,
|
|
941
|
+
objectId: item.objectId ?? item.sha256 ?? "",
|
|
942
|
+
})),
|
|
370
943
|
}),
|
|
371
944
|
sourceSize: sourceBytes,
|
|
372
945
|
storedSize: storedBytes,
|
|
946
|
+
/*
|
|
947
|
+
One shape or the other, never both — the service refuses a file that
|
|
948
|
+
names an object and a chunk list, and so does its database.
|
|
949
|
+
*/
|
|
373
950
|
files: contents.map((item) => ({
|
|
374
951
|
path: item.path,
|
|
375
|
-
|
|
952
|
+
...(item.pack
|
|
953
|
+
? {
|
|
954
|
+
pack: {
|
|
955
|
+
objectId: item.pack.packObjectId,
|
|
956
|
+
offset: item.pack.offset,
|
|
957
|
+
length: item.pack.length,
|
|
958
|
+
},
|
|
959
|
+
sha256: item.sha256,
|
|
960
|
+
}
|
|
961
|
+
: item.chunks
|
|
962
|
+
? { chunks: item.chunks, sha256: item.sha256 }
|
|
963
|
+
: { objectId: item.objectId }),
|
|
376
964
|
sourceSize: item.sourceSize,
|
|
377
965
|
storedSize: item.storedSize,
|
|
378
966
|
mediaType: item.mediaType,
|
|
@@ -439,8 +1027,26 @@ class Uploader {
|
|
|
439
1027
|
method: "GET",
|
|
440
1028
|
});
|
|
441
1029
|
for (const row of body.files ?? []) {
|
|
1030
|
+
const pieces = Array.isArray(row.chunks)
|
|
1031
|
+
? row.chunks.map((chunk) => ({
|
|
1032
|
+
objectId: String(chunk.objectId ?? ""),
|
|
1033
|
+
sourceSize: Number(chunk.sourceSize ?? 0),
|
|
1034
|
+
storedSize: Number(chunk.storedSize ?? 0),
|
|
1035
|
+
}))
|
|
1036
|
+
: undefined;
|
|
1037
|
+
const packed = row.pack;
|
|
442
1038
|
held.set(String(row.path ?? ""), {
|
|
443
1039
|
objectId: String(row.objectId ?? ""),
|
|
1040
|
+
...(packed?.objectId
|
|
1041
|
+
? {
|
|
1042
|
+
pack: {
|
|
1043
|
+
objectId: String(packed.objectId),
|
|
1044
|
+
offset: Number(packed.offset ?? 0),
|
|
1045
|
+
length: Number(packed.length ?? 0),
|
|
1046
|
+
},
|
|
1047
|
+
}
|
|
1048
|
+
: {}),
|
|
1049
|
+
...(pieces?.length ? { chunks: pieces } : {}),
|
|
444
1050
|
sha256: String(row.sha256 ?? ""),
|
|
445
1051
|
sourceSize: Number(row.sourceSize ?? 0),
|
|
446
1052
|
storedSize: Number(row.storedSize ?? 0),
|
|
@@ -498,6 +1104,298 @@ class Uploader {
|
|
|
498
1104
|
return null;
|
|
499
1105
|
}
|
|
500
1106
|
}
|
|
1107
|
+
/**
|
|
1108
|
+
* Send many small files compressed together as one pack.
|
|
1109
|
+
*
|
|
1110
|
+
* Returns where each file ended up inside the stored pack, or null if the
|
|
1111
|
+
* service would not take it — in which case the caller falls back to sending
|
|
1112
|
+
* them separately, which is slower and larger but always works.
|
|
1113
|
+
*/
|
|
1114
|
+
async putPack(repositoryId, items) {
|
|
1115
|
+
const built = await (0, solid_js_1.buildSolidPack)(items.map((item) => ({
|
|
1116
|
+
sha256: item.declaration.sha256,
|
|
1117
|
+
body: item.body,
|
|
1118
|
+
})));
|
|
1119
|
+
if (!built)
|
|
1120
|
+
return null;
|
|
1121
|
+
const manifest = JSON.stringify({
|
|
1122
|
+
sha256: built.sha256,
|
|
1123
|
+
storedSha256: built.storedSha256,
|
|
1124
|
+
size: built.size,
|
|
1125
|
+
members: built.members,
|
|
1126
|
+
});
|
|
1127
|
+
const manifestBytes = new TextEncoder().encode(manifest);
|
|
1128
|
+
const packed = new Uint8Array(4 + manifestBytes.byteLength + built.body.byteLength);
|
|
1129
|
+
new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
|
|
1130
|
+
packed.set(manifestBytes, 4);
|
|
1131
|
+
packed.set(built.body, 4 + manifestBytes.byteLength);
|
|
1132
|
+
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/pack`, {
|
|
1133
|
+
method: "POST",
|
|
1134
|
+
contentType: "application/octet-stream",
|
|
1135
|
+
body: packed,
|
|
1136
|
+
});
|
|
1137
|
+
const placed = new Map();
|
|
1138
|
+
/*
|
|
1139
|
+
Each file's share of what the pack actually costs.
|
|
1140
|
+
|
|
1141
|
+
A packed file has no stored size of its own — the pack was charged once
|
|
1142
|
+
for all of them — but a version still has to say what it costs, and
|
|
1143
|
+
reporting each file's uncompressed length made a pack that halved the
|
|
1144
|
+
storage look like it had saved nothing. Apportioned by length, so the
|
|
1145
|
+
shares add up to the pack and the totals stay true.
|
|
1146
|
+
*/
|
|
1147
|
+
const compressed = Number(answer.storedSize) || built.body.byteLength;
|
|
1148
|
+
for (const member of built.members) {
|
|
1149
|
+
placed.set(member.sha256, {
|
|
1150
|
+
packObjectId: answer.packObjectId,
|
|
1151
|
+
offset: member.offset,
|
|
1152
|
+
length: member.length,
|
|
1153
|
+
storedSize: built.size
|
|
1154
|
+
? Math.round((member.length / built.size) * compressed)
|
|
1155
|
+
: 0,
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
return placed;
|
|
1159
|
+
}
|
|
1160
|
+
/**
|
|
1161
|
+
* Send many small objects in one request.
|
|
1162
|
+
*
|
|
1163
|
+
* Returns what landed, keyed by digest. Anything the service could not take
|
|
1164
|
+
* is simply absent, and the caller sends those the ordinary way — these are
|
|
1165
|
+
* content-addressed, so a retry is free and a partial batch costs nothing
|
|
1166
|
+
* but the objects it missed.
|
|
1167
|
+
*/
|
|
1168
|
+
async putBatch(repositoryId, items) {
|
|
1169
|
+
const landed = new Map();
|
|
1170
|
+
if (!items.length)
|
|
1171
|
+
return landed;
|
|
1172
|
+
const manifest = JSON.stringify({
|
|
1173
|
+
objects: items.map((item) => ({
|
|
1174
|
+
sha256: item.declaration.sha256,
|
|
1175
|
+
size: item.body.byteLength,
|
|
1176
|
+
storedSize: item.encoded.body.byteLength,
|
|
1177
|
+
mediaType: item.declaration.mediaType,
|
|
1178
|
+
encoding: item.encoded.encoding,
|
|
1179
|
+
...(item.encoded.encoding === "gzip"
|
|
1180
|
+
? { storedSha256: item.encoded.storedSha256 }
|
|
1181
|
+
: {}),
|
|
1182
|
+
})),
|
|
1183
|
+
});
|
|
1184
|
+
const manifestBytes = new TextEncoder().encode(manifest);
|
|
1185
|
+
const total = 4 +
|
|
1186
|
+
manifestBytes.byteLength +
|
|
1187
|
+
items.reduce((sum, item) => sum + item.encoded.body.byteLength, 0);
|
|
1188
|
+
const packed = new Uint8Array(total);
|
|
1189
|
+
new DataView(packed.buffer).setUint32(0, manifestBytes.byteLength, false);
|
|
1190
|
+
packed.set(manifestBytes, 4);
|
|
1191
|
+
let at = 4 + manifestBytes.byteLength;
|
|
1192
|
+
for (const item of items) {
|
|
1193
|
+
packed.set(item.encoded.body, at);
|
|
1194
|
+
at += item.encoded.body.byteLength;
|
|
1195
|
+
}
|
|
1196
|
+
const answer = await this.call(`/v1/repositories/${repositoryId}/objects/batch`, {
|
|
1197
|
+
method: "POST",
|
|
1198
|
+
contentType: "application/octet-stream",
|
|
1199
|
+
body: packed,
|
|
1200
|
+
});
|
|
1201
|
+
for (const one of answer.objects ?? []) {
|
|
1202
|
+
landed.set(one.sha256, {
|
|
1203
|
+
objectId: one.objectId,
|
|
1204
|
+
size: one.storedSize,
|
|
1205
|
+
});
|
|
1206
|
+
}
|
|
1207
|
+
return landed;
|
|
1208
|
+
}
|
|
1209
|
+
/**
|
|
1210
|
+
* Which of these digests the account already holds, in one request.
|
|
1211
|
+
*
|
|
1212
|
+
* Falls back to an empty answer rather than an error: an older deployment
|
|
1213
|
+
* has no such route, and the per-file lookup below still works, so the
|
|
1214
|
+
* upload is slower and never broken.
|
|
1215
|
+
*/
|
|
1216
|
+
async storedInBulk(repositoryId, digests) {
|
|
1217
|
+
const held = new Map();
|
|
1218
|
+
if (!digests.length)
|
|
1219
|
+
return held;
|
|
1220
|
+
try {
|
|
1221
|
+
/*
|
|
1222
|
+
Split so one request stays a reasonable size on both ends. Five
|
|
1223
|
+
thousand digests is roughly a third of a megabyte of JSON, which is
|
|
1224
|
+
nothing beside the transfer it is replacing.
|
|
1225
|
+
*/
|
|
1226
|
+
for (let at = 0; at < digests.length; at += 5000) {
|
|
1227
|
+
this.check();
|
|
1228
|
+
const batch = [...new Set(digests.slice(at, at + 5000))];
|
|
1229
|
+
const answer = await this.call(`/v1/repositories/${repositoryId}/stored`, {
|
|
1230
|
+
method: "POST",
|
|
1231
|
+
contentType: "application/json",
|
|
1232
|
+
body: JSON.stringify({ sha256: batch }),
|
|
1233
|
+
});
|
|
1234
|
+
for (const [digest, one] of Object.entries(answer.stored ?? {})) {
|
|
1235
|
+
held.set(digest, { objectId: one.objectId, size: one.storedSize });
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
catch {
|
|
1240
|
+
/*
|
|
1241
|
+
Unknown route, or a refusal. Answering "nothing is held" is always
|
|
1242
|
+
safe: every file is then asked about individually, exactly as before.
|
|
1243
|
+
*/
|
|
1244
|
+
return new Map();
|
|
1245
|
+
}
|
|
1246
|
+
return held;
|
|
1247
|
+
}
|
|
1248
|
+
/**
|
|
1249
|
+
* Send one file as content-defined chunks, reusing whatever is already held.
|
|
1250
|
+
*
|
|
1251
|
+
* The boundaries come from CBX's chunker, so they depend on the bytes rather
|
|
1252
|
+
* than on offsets: inserting a line near the top of a large file shifts one
|
|
1253
|
+
* chunk instead of every chunk after it. Each piece is an ordinary
|
|
1254
|
+
* content-addressed object, which is what makes "the service already has
|
|
1255
|
+
* this one" a question the existing protocol can already answer.
|
|
1256
|
+
*/
|
|
1257
|
+
async putChunked(repositoryId, declaration, onOffset) {
|
|
1258
|
+
/*
|
|
1259
|
+
Read in windows, not all at once.
|
|
1260
|
+
|
|
1261
|
+
This used to load the whole file to chunk it, which for the files that
|
|
1262
|
+
reach this path — everything past sixteen megabytes — meant an allocation
|
|
1263
|
+
the size of the file. A three gigabyte video was a three gigabyte buffer
|
|
1264
|
+
and an out-of-memory crash partway through an upload, which is the one
|
|
1265
|
+
failure worse than a slow one: it happens after the work.
|
|
1266
|
+
|
|
1267
|
+
The window holds at most one read plus one maximum chunk, whatever the
|
|
1268
|
+
file is. Exactly how cbx.ts packs a bundle, and for the same reason.
|
|
1269
|
+
*/
|
|
1270
|
+
const chunks = [];
|
|
1271
|
+
let reused = 0;
|
|
1272
|
+
let sent = 0;
|
|
1273
|
+
let done = 0;
|
|
1274
|
+
const note = (kind, bytes) => {
|
|
1275
|
+
if (kind === "reused")
|
|
1276
|
+
reused += 1;
|
|
1277
|
+
else
|
|
1278
|
+
sent += bytes;
|
|
1279
|
+
};
|
|
1280
|
+
/*
|
|
1281
|
+
A file's chunks go up several at a time.
|
|
1282
|
+
|
|
1283
|
+
They were sent strictly one after another, which meant one large file
|
|
1284
|
+
used exactly one lane however many were free. Measured on a gigabyte
|
|
1285
|
+
folder, throughput fell from 4 MB/s to 0.97 MB/s as the small files ran
|
|
1286
|
+
out and only the big one was left — five lanes idle while one crawled.
|
|
1287
|
+
For a folder that is *one* large file, that was the whole upload.
|
|
1288
|
+
|
|
1289
|
+
Bounded tighter than the file lanes because each one holds a piece in
|
|
1290
|
+
memory: four in flight of at most a maximum chunk each, rather than six.
|
|
1291
|
+
*/
|
|
1292
|
+
const CHUNK_LANES = 4;
|
|
1293
|
+
const active = new Set();
|
|
1294
|
+
const dispatch = async (piece, at) => {
|
|
1295
|
+
const task = this.sendChunk(repositoryId, declaration, piece, chunks, at, note).finally(() => {
|
|
1296
|
+
active.delete(task);
|
|
1297
|
+
done += piece.length;
|
|
1298
|
+
onOffset(done);
|
|
1299
|
+
});
|
|
1300
|
+
active.add(task);
|
|
1301
|
+
if (active.size >= CHUNK_LANES)
|
|
1302
|
+
await Promise.race(active);
|
|
1303
|
+
};
|
|
1304
|
+
let ordinal = 0;
|
|
1305
|
+
let pending = Buffer.alloc(0);
|
|
1306
|
+
for await (const block of (0, node_fs_1.createReadStream)(declaration.full, {
|
|
1307
|
+
highWaterMark: 8 * 1024 * 1024,
|
|
1308
|
+
})) {
|
|
1309
|
+
this.check();
|
|
1310
|
+
const incoming = Buffer.from(block);
|
|
1311
|
+
pending = pending.length ? Buffer.concat([pending, incoming]) : incoming;
|
|
1312
|
+
let from = 0;
|
|
1313
|
+
for (const cut of (0, cbx_js_1.cutPoints)(pending)) {
|
|
1314
|
+
await dispatch(Buffer.from(pending.subarray(from, cut)), ordinal++);
|
|
1315
|
+
from = cut;
|
|
1316
|
+
}
|
|
1317
|
+
pending = Buffer.from(pending.subarray(from));
|
|
1318
|
+
}
|
|
1319
|
+
/*
|
|
1320
|
+
cutPoints returns the boundaries *inside* what it was given and never the
|
|
1321
|
+
end, so whatever is left after the last cut is a chunk it does not
|
|
1322
|
+
mention — the packer in cbx.ts emits that tail itself, after its loop.
|
|
1323
|
+
Dropping it truncates every chunked file by exactly its last piece.
|
|
1324
|
+
*/
|
|
1325
|
+
if (pending.length)
|
|
1326
|
+
await dispatch(pending, ordinal++);
|
|
1327
|
+
await Promise.all(active);
|
|
1328
|
+
/*
|
|
1329
|
+
Order is the file. Every position must be filled: a hole would mean a
|
|
1330
|
+
chunk that never landed, and publishing around it would produce a version
|
|
1331
|
+
whose bytes are individually verified and collectively wrong.
|
|
1332
|
+
*/
|
|
1333
|
+
const ordered = chunks.slice(0, ordinal);
|
|
1334
|
+
if (ordered.some((one) => !one)) {
|
|
1335
|
+
throw new Error(`${declaration.path}: a chunk did not finish uploading`);
|
|
1336
|
+
}
|
|
1337
|
+
return {
|
|
1338
|
+
chunks: ordered,
|
|
1339
|
+
reused,
|
|
1340
|
+
sentBytes: sent,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
/** Send one chunk, reusing it if the account already holds those bytes. */
|
|
1344
|
+
async sendChunk(repositoryId, declaration, piece,
|
|
1345
|
+
/*
|
|
1346
|
+
Written at a known position rather than appended. The chunks are sent
|
|
1347
|
+
several at a time and finish in whatever order the network decides, but
|
|
1348
|
+
their order *is* the file — appending as they land would reassemble a
|
|
1349
|
+
large file out of sequence, which produces bytes that are all present,
|
|
1350
|
+
all verified individually, and wrong.
|
|
1351
|
+
*/
|
|
1352
|
+
chunks, at, note) {
|
|
1353
|
+
{
|
|
1354
|
+
this.check();
|
|
1355
|
+
const digest = (0, node_crypto_1.createHash)("sha256").update(piece).digest("hex");
|
|
1356
|
+
/*
|
|
1357
|
+
Asked per chunk rather than per file. This is the whole saving: the
|
|
1358
|
+
parts of a large file that did not change answer "already held" and
|
|
1359
|
+
never travel.
|
|
1360
|
+
*/
|
|
1361
|
+
const held = await this.findStored(repositoryId, {
|
|
1362
|
+
...declaration,
|
|
1363
|
+
sha256: digest,
|
|
1364
|
+
size: piece.length,
|
|
1365
|
+
});
|
|
1366
|
+
if (held) {
|
|
1367
|
+
note("reused", 0);
|
|
1368
|
+
chunks[at] = {
|
|
1369
|
+
objectId: held.objectId,
|
|
1370
|
+
sourceSize: piece.length,
|
|
1371
|
+
storedSize: held.size,
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
else {
|
|
1375
|
+
const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(piece), declaration.path, await this.gzipAllowed());
|
|
1376
|
+
const query = encoded.encoding === "gzip"
|
|
1377
|
+
? `?kind=chunk&role=chunk&encoding=gzip` +
|
|
1378
|
+
`&logicalSize=${piece.length}` +
|
|
1379
|
+
`&storedSha256=${encoded.storedSha256}`
|
|
1380
|
+
: `?kind=chunk&role=chunk`;
|
|
1381
|
+
const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
|
|
1382
|
+
method: "PUT",
|
|
1383
|
+
contentType: "application/octet-stream",
|
|
1384
|
+
body: encoded.body,
|
|
1385
|
+
}).catch((error) => {
|
|
1386
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
1387
|
+
throw new Error(`${declaration.path}: ${reason}`);
|
|
1388
|
+
});
|
|
1389
|
+
note("sent", encoded.body.byteLength);
|
|
1390
|
+
chunks[at] = {
|
|
1391
|
+
objectId: stored.objectId,
|
|
1392
|
+
sourceSize: piece.length,
|
|
1393
|
+
/* What the service keeps, which is smaller when the piece gzipped. */
|
|
1394
|
+
storedSize: stored.storedSize ?? stored.size,
|
|
1395
|
+
};
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
}
|
|
501
1399
|
async putDirect(repositoryId, declaration) {
|
|
502
1400
|
const body = await (0, promises_1.readFile)(declaration.full);
|
|
503
1401
|
// The file was hashed earlier, and a live file — a database, a log — can
|
|
@@ -508,11 +1406,97 @@ class Uploader {
|
|
|
508
1406
|
declaration.sha256 = digest;
|
|
509
1407
|
declaration.size = body.length;
|
|
510
1408
|
}
|
|
511
|
-
|
|
1409
|
+
/*
|
|
1410
|
+
The digest in the path stays the original file's, whatever travels.
|
|
1411
|
+
|
|
1412
|
+
That is the whole reason an object can be sent compressed at all: its
|
|
1413
|
+
identity is what it *is*, not how it was packed, so a file the service
|
|
1414
|
+
already holds is recognised as held even if the two clients that sent it
|
|
1415
|
+
made different choices about compression.
|
|
1416
|
+
*/
|
|
1417
|
+
const encoded = await (0, compress_js_1.encodeForUpload)(new Uint8Array(body), declaration.path, await this.gzipAllowed());
|
|
1418
|
+
const query = encoded.encoding === "gzip"
|
|
1419
|
+
? `?kind=chunk&role=chunk&encoding=gzip` +
|
|
1420
|
+
`&logicalSize=${body.length}` +
|
|
1421
|
+
`&storedSha256=${encoded.storedSha256}`
|
|
1422
|
+
: `?kind=chunk&role=chunk`;
|
|
1423
|
+
/*
|
|
1424
|
+
The answer carries both sizes and they are not the same thing: `size` is
|
|
1425
|
+
the file's own length, `storedSize` is what the service actually keeps.
|
|
1426
|
+
They agree only while nothing is compressed, which is why recording the
|
|
1427
|
+
wrong one went unnoticed until a compressed object was sent — and then
|
|
1428
|
+
the version is refused for declaring a size the object does not have.
|
|
1429
|
+
*/
|
|
1430
|
+
const stored = await this.call(`/v1/repositories/${repositoryId}/objects/${digest}${query}`, {
|
|
512
1431
|
method: "PUT",
|
|
513
1432
|
contentType: declaration.mediaType,
|
|
514
|
-
body:
|
|
1433
|
+
body: encoded.body,
|
|
515
1434
|
});
|
|
1435
|
+
return {
|
|
1436
|
+
objectId: stored.objectId,
|
|
1437
|
+
size: stored.storedSize ?? stored.size,
|
|
1438
|
+
};
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Whether this deployment records gzipped objects.
|
|
1442
|
+
*
|
|
1443
|
+
* Asked once and remembered. Sending an encoding the service cannot record
|
|
1444
|
+
* is refused outright, so this is not an optimisation to guess at — an older
|
|
1445
|
+
* deployment answers without gzip and every upload simply goes as it always
|
|
1446
|
+
* did.
|
|
1447
|
+
*/
|
|
1448
|
+
/**
|
|
1449
|
+
* Whether this deployment can store a file as chunks.
|
|
1450
|
+
*
|
|
1451
|
+
* Asked rather than assumed, because the app updates on its own schedule and
|
|
1452
|
+
* the service updates on another: a build that started chunking against a
|
|
1453
|
+
* deployment without the table behind it would fail every large upload, and
|
|
1454
|
+
* fail it after doing all the work. An older service answers no and files go
|
|
1455
|
+
* up whole exactly as they always did.
|
|
1456
|
+
*/
|
|
1457
|
+
async packingAllowed() {
|
|
1458
|
+
if (this.packingSupported !== null)
|
|
1459
|
+
return this.packingSupported;
|
|
1460
|
+
this.packingSupported = (await this.serviceFeatures()).includes("solid-packs");
|
|
1461
|
+
return this.packingSupported;
|
|
1462
|
+
}
|
|
1463
|
+
async chunkingAllowed() {
|
|
1464
|
+
if (this.chunkingSupported !== null)
|
|
1465
|
+
return this.chunkingSupported;
|
|
1466
|
+
this.chunkingSupported = (await this.serviceFeatures()).includes("chunked-files");
|
|
1467
|
+
return this.chunkingSupported;
|
|
1468
|
+
}
|
|
1469
|
+
/** What /health says this deployment accepts. Fetched once. */
|
|
1470
|
+
async serviceFeatures() {
|
|
1471
|
+
try {
|
|
1472
|
+
const response = await fetch(`${this.credentials.origin()}/health`, {
|
|
1473
|
+
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
1474
|
+
signal: AbortSignal.timeout(8000),
|
|
1475
|
+
});
|
|
1476
|
+
const body = (await response.json());
|
|
1477
|
+
return body.features ?? [];
|
|
1478
|
+
}
|
|
1479
|
+
catch {
|
|
1480
|
+
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
1481
|
+
return [];
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
async gzipAllowed() {
|
|
1485
|
+
if (this.gzipSupported !== null)
|
|
1486
|
+
return this.gzipSupported;
|
|
1487
|
+
try {
|
|
1488
|
+
const response = await fetch(`${this.credentials.origin()}/health`, {
|
|
1489
|
+
headers: { accept: "application/json", ...(0, identify_js_1.clientHeaders)() },
|
|
1490
|
+
signal: AbortSignal.timeout(8000),
|
|
1491
|
+
});
|
|
1492
|
+
const body = (await response.json());
|
|
1493
|
+
this.gzipSupported = Boolean(body.contentEncodings?.includes("gzip"));
|
|
1494
|
+
}
|
|
1495
|
+
catch {
|
|
1496
|
+
/* Unknown is treated as unsupported: never risk a refused upload. */
|
|
1497
|
+
this.gzipSupported = false;
|
|
1498
|
+
}
|
|
1499
|
+
return this.gzipSupported;
|
|
516
1500
|
}
|
|
517
1501
|
/** Files past the direct limit go up in parts under an upload session. */
|
|
518
1502
|
async putMultipart(repositoryId, declaration, onOffset) {
|