@teepin/sdk 0.3.0 → 0.4.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/dist/cjs/client.js +3 -1
- package/dist/cjs/client.js.map +1 -1
- package/dist/cjs/direct/engine.js +49 -59
- package/dist/cjs/direct/engine.js.map +1 -1
- package/dist/cjs/direct/packing.js +549 -0
- package/dist/cjs/direct/packing.js.map +1 -0
- package/dist/cjs/direct/uploads.js +336 -64
- package/dist/cjs/direct/uploads.js.map +1 -1
- package/dist/esm/client.js +3 -1
- package/dist/esm/client.js.map +1 -1
- package/dist/esm/direct/engine.js +45 -55
- package/dist/esm/direct/engine.js.map +1 -1
- package/dist/esm/direct/packing.js +536 -0
- package/dist/esm/direct/packing.js.map +1 -0
- package/dist/esm/direct/uploads.js +337 -65
- package/dist/esm/direct/uploads.js.map +1 -1
- package/dist/types/client.d.ts +1 -1
- package/dist/types/client.d.ts.map +1 -1
- package/dist/types/direct/engine.d.ts +37 -21
- package/dist/types/direct/engine.d.ts.map +1 -1
- package/dist/types/direct/packing.d.ts +207 -0
- package/dist/types/direct/packing.d.ts.map +1 -0
- package/dist/types/direct/uploads.d.ts +66 -13
- package/dist/types/direct/uploads.d.ts.map +1 -1
- package/package.json +2 -2
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
import { APIError, ConnectionError, DataIntegrityError, FileChangedError, ForbiddenError, PaymentRequiredError, PreconditionFailedError, StorageUnavailableError, TeepinError, UnauthorizedError, apiError, } from '../errors.js';
|
|
4
4
|
import { md5Hex } from '../md5.js';
|
|
5
5
|
import { fromBase64 } from './bytes.js';
|
|
6
|
-
import { AdaptiveLimit,
|
|
6
|
+
import { AdaptiveLimit, COMMIT_MS_PER_OBJECT, COMMIT_TIMEOUT_MS, MAX_REBUILDS, PACK_OBJECT_LIMIT, PART_RULES, RESEND_AFTER_LOST_MS, RESEND_AFTER_SENT_MS, RESERVE_MS_PER_OBJECT, RetryBudget, SIGN_MARGIN_MS, SIGN_MS_PER_OBJECT, START_CONCURRENCY, STREAM_CONCURRENCY, SpeedMeter, directPartSize, envNumber, isPassing, isThrottle, bodyTransferMs, packObjectLimit, scaledTimeoutMs, storageOutcome, verifyDelayMs, streamPartSize, waitDelayMs, } from './engine.js';
|
|
7
7
|
import { abortable, Deferred, joinCapped, linked, objectFrom, Progress, toBase64, uuid7 } from './common.js';
|
|
8
8
|
import * as hpke from './hpke.js';
|
|
9
|
+
import { Arrivals, DEFAULT_LIMITS, InFlight, MEMORY_CAP, absorbCap, atSpeed, limitsFromJSON, planPack, schedule, } from './packing.js';
|
|
9
10
|
import { MemoryJournal } from './journal.js';
|
|
10
11
|
import { Large, Parts, StreamUpload } from './large.js';
|
|
11
12
|
import { Sha256 } from './sha256.js';
|
|
@@ -131,6 +132,11 @@ export class Item {
|
|
|
131
132
|
wake;
|
|
132
133
|
/** The pack and object id its file row last journaled. */
|
|
133
134
|
journaled = '';
|
|
135
|
+
/** A run's place for the file (items given back to the planner keep it). */
|
|
136
|
+
order = 0;
|
|
137
|
+
/** Rebuilds of the packs it was in, carried when a pack gives it back, so MAX_REBUILDS holds. */
|
|
138
|
+
rebuilds = 0;
|
|
139
|
+
reqBytes = 0;
|
|
134
140
|
contentType;
|
|
135
141
|
metadata;
|
|
136
142
|
ifMatch;
|
|
@@ -146,6 +152,16 @@ export class Item {
|
|
|
146
152
|
this.metadata = { ...(metadata ?? {}) };
|
|
147
153
|
this.ifMatch = ifMatch || '';
|
|
148
154
|
}
|
|
155
|
+
/** Its bytes in a pack: its needle and TWS1 bytes. */
|
|
156
|
+
get stored() {
|
|
157
|
+
return tws1.NEEDLE_HEADER_SIZE + tws1.storedSize(this.size);
|
|
158
|
+
}
|
|
159
|
+
/** The bytes it adds to a reserve body (section 28.17): its JSON object and a separator. */
|
|
160
|
+
get request() {
|
|
161
|
+
if (!this.reqBytes)
|
|
162
|
+
this.reqBytes = new TextEncoder().encode(JSON.stringify(this.reserveJSON())).length + 2;
|
|
163
|
+
return this.reqBytes;
|
|
164
|
+
}
|
|
149
165
|
reserveJSON() {
|
|
150
166
|
const o = { key: this.key, size: this.size, sha256: this.sha256 };
|
|
151
167
|
if (this.contentType)
|
|
@@ -159,6 +175,11 @@ export class Item {
|
|
|
159
175
|
return o;
|
|
160
176
|
}
|
|
161
177
|
}
|
|
178
|
+
/** A pack's states before its first signature: its bytes can't be on the storage network yet. */
|
|
179
|
+
const UNSIGNED = new Set(['new', 'reserved', 'built', 'conflict', 'bad']);
|
|
180
|
+
const itemStored = (it) => it.stored;
|
|
181
|
+
const itemKey = (it) => it.key;
|
|
182
|
+
const itemRequest = (it) => it.request;
|
|
162
183
|
/** One pack's state, kept in its store (a run's journal, or memory). */
|
|
163
184
|
export class Pack {
|
|
164
185
|
bucket;
|
|
@@ -182,6 +203,18 @@ export class Pack {
|
|
|
182
203
|
waited = 0;
|
|
183
204
|
throttled = false;
|
|
184
205
|
lastError = '';
|
|
206
|
+
/** The reason for a pack under the floor (section 28.9), sent with its reserve. */
|
|
207
|
+
underFloor;
|
|
208
|
+
/** Its bytes counted against MEMORY_CAP while in flight (putObject's packs). */
|
|
209
|
+
inflight = 0;
|
|
210
|
+
/** When its upload ended (in memory only), whether that answer was lost, and the checking waits since (28.18). */
|
|
211
|
+
sentAt;
|
|
212
|
+
sentUnknown = false;
|
|
213
|
+
verifyWaited = 0;
|
|
214
|
+
/** A run's packs give their files back to its planner instead of renewing under the floor (28.5). */
|
|
215
|
+
giveback;
|
|
216
|
+
/** Resumed, unsigned and under the floor (28.22 point 5): its idempotency key is asked first. */
|
|
217
|
+
dissolve = false;
|
|
185
218
|
constructor(bucket, items, store, id) {
|
|
186
219
|
this.bucket = bucket;
|
|
187
220
|
this.items = items;
|
|
@@ -355,11 +388,12 @@ export class DirectUploads {
|
|
|
355
388
|
sizes = {
|
|
356
389
|
...PART_RULES,
|
|
357
390
|
packObjectLimit: PACK_OBJECT_LIMIT,
|
|
358
|
-
packTarget: PACK_TARGET_BYTES,
|
|
359
|
-
packMinTarget: PACK_MIN_TARGET,
|
|
360
|
-
packMaxObjects: PACK_MAX_OBJECTS,
|
|
361
391
|
streamConcurrency: STREAM_CONCURRENCY,
|
|
362
392
|
};
|
|
393
|
+
/** The pack sizes and timings (section 28): built in, then the server's from each reservation. Tests set them. */
|
|
394
|
+
limits = { ...DEFAULT_LIMITS };
|
|
395
|
+
/** The timing rule's monotonic clock in milliseconds (tests replace it). */
|
|
396
|
+
clock = () => performance.now();
|
|
363
397
|
/**
|
|
364
398
|
* The measured upload speed to the storage network: shrinks packs and new uploads' parts on a slow link (section
|
|
365
399
|
* 22.7). Tests set it with speed.set.
|
|
@@ -370,6 +404,14 @@ export class DirectUploads {
|
|
|
370
404
|
* slower, twice its time at that speed (section 22.7). Tests shorten it.
|
|
371
405
|
*/
|
|
372
406
|
putTimeout = { minMs: 30000, bytesPerSecond: 65536 };
|
|
407
|
+
/**
|
|
408
|
+
* A pack call's timeout (29.15): the server's time for `objects` objects, scaled, plus sending `body` (none for a
|
|
409
|
+
* commit) at the speed measured so far; never under `floorMs`.
|
|
410
|
+
*/
|
|
411
|
+
callTimeoutMs(objects, perObjectMs, floorMs, body) {
|
|
412
|
+
const n = body === undefined ? 0 : new TextEncoder().encode(JSON.stringify(body)).length;
|
|
413
|
+
return scaledTimeoutMs(objects, perObjectMs, floorMs, bodyTransferMs(n, this.speed.bytesPerSecond, this.putTimeout.bytesPerSecond));
|
|
414
|
+
}
|
|
373
415
|
/** How long a stopping step waits for its tasks, and a cleanup call may take (ms). */
|
|
374
416
|
cleanupMs = CLEANUP_MS;
|
|
375
417
|
digests = webDigests;
|
|
@@ -378,8 +420,17 @@ export class DirectUploads {
|
|
|
378
420
|
host;
|
|
379
421
|
/** Where warnings go: a run removed by cleanUploads, a large state directory. */
|
|
380
422
|
warn = (m) => console.warn(m);
|
|
423
|
+
/** Where a line worth knowing but not a warning goes (a purged pack's commit, 29.15); tests replace it. */
|
|
424
|
+
info = (m) => console.info(m);
|
|
381
425
|
accountId;
|
|
382
|
-
|
|
426
|
+
/** putObject's waiting files, per bucket, with their running totals (section 28.17). */
|
|
427
|
+
waiting = new Map();
|
|
428
|
+
/** When the timing rule next needs a decision, if it does. */
|
|
429
|
+
nextDecision;
|
|
430
|
+
/** putObject's pack bytes in flight, against MEMORY_CAP. */
|
|
431
|
+
inflight = 0;
|
|
432
|
+
/** putObject's packs in flight and when each is expected back (section 28.25). */
|
|
433
|
+
inFlight = new InFlight();
|
|
383
434
|
/** Wake-ups of putObject calls waiting for a slot: resolved when one comes free. */
|
|
384
435
|
slotWaiters = [];
|
|
385
436
|
memory = new PackStore();
|
|
@@ -457,10 +508,6 @@ export class DirectUploads {
|
|
|
457
508
|
packObjectLimit() {
|
|
458
509
|
return packObjectLimit(this.speed.requestBytes(), this.sizes.packObjectLimit, this.sizes.minBytes);
|
|
459
510
|
}
|
|
460
|
-
/** The next pack's target size, at the speed measured so far. */
|
|
461
|
-
packTarget() {
|
|
462
|
-
return packTarget(this.speed.requestBytes(), this.sizes.packTarget, this.sizes.packMinTarget);
|
|
463
|
-
}
|
|
464
511
|
// --- shared plumbing ----------------------------------------------------------
|
|
465
512
|
/** Where runs keep their state, when it's on disk. */
|
|
466
513
|
get runHost() {
|
|
@@ -548,7 +595,9 @@ export class DirectUploads {
|
|
|
548
595
|
*/
|
|
549
596
|
async drive(p, signal) {
|
|
550
597
|
try {
|
|
551
|
-
|
|
598
|
+
if (p.dissolve)
|
|
599
|
+
await this.dissolvePack(p, signal);
|
|
600
|
+
while (p.state !== 'committed' && p.state !== 'failed' && p.state !== 'returned') {
|
|
552
601
|
signal?.throwIfAborted();
|
|
553
602
|
switch (p.state) {
|
|
554
603
|
case 'new':
|
|
@@ -601,9 +650,11 @@ export class DirectUploads {
|
|
|
601
650
|
async reserve(p, signal) {
|
|
602
651
|
await p.store.savePack(p); // the idempotency key, before the call
|
|
603
652
|
const body = { idempotency_key: p.idem, objects: p.items.map((it) => it.reserveJSON()) };
|
|
653
|
+
if (p.underFloor)
|
|
654
|
+
body.under_floor = p.underFloor;
|
|
604
655
|
let res;
|
|
605
656
|
try {
|
|
606
|
-
res = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs`, body, p, signal)).data;
|
|
657
|
+
res = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs`, body, p, signal, this.callTimeoutMs(p.items.length, RESERVE_MS_PER_OBJECT, this.client.timeout, body))).data;
|
|
607
658
|
}
|
|
608
659
|
catch (err) {
|
|
609
660
|
if (err instanceof APIError && err.code === 'reservation_abandoned') {
|
|
@@ -621,6 +672,9 @@ export class DirectUploads {
|
|
|
621
672
|
throw err;
|
|
622
673
|
}
|
|
623
674
|
await this.checkKey(res.account_key);
|
|
675
|
+
const limits = limitsFromJSON(res.limits);
|
|
676
|
+
if (limits)
|
|
677
|
+
this.limits = limits;
|
|
624
678
|
const ids = new Map((res.objects ?? []).map((o) => [str(o.key), str(o.object_id)]));
|
|
625
679
|
for (const it of p.items)
|
|
626
680
|
it.objectId = ids.get(it.key) ?? '';
|
|
@@ -636,6 +690,9 @@ export class DirectUploads {
|
|
|
636
690
|
// The reservation names files that changed: the rest go under a new one.
|
|
637
691
|
const gone = new Set(built.changed.map(([it]) => it));
|
|
638
692
|
p.items = p.items.filter((it) => !gone.has(it));
|
|
693
|
+
// A run's other files go back to its planner, so they never go as a smaller pack (section 28.5).
|
|
694
|
+
if (p.giveback && p.items.length)
|
|
695
|
+
return this.giveBack(p);
|
|
639
696
|
p.state = p.items.length ? 'conflict' : 'failed';
|
|
640
697
|
p.rebuilds--; // not the pack's fault
|
|
641
698
|
await p.store.savePack(p);
|
|
@@ -709,7 +766,7 @@ export class DirectUploads {
|
|
|
709
766
|
async sign(p, signal) {
|
|
710
767
|
let signed;
|
|
711
768
|
try {
|
|
712
|
-
signed = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/sign`, p.signBody, p, signal)).data;
|
|
769
|
+
signed = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/sign`, p.signBody, p, signal, this.callTimeoutMs(p.items.length, SIGN_MS_PER_OBJECT, this.client.timeout, p.signBody))).data;
|
|
713
770
|
}
|
|
714
771
|
catch (err) {
|
|
715
772
|
if (err instanceof APIError && err.code === 'reservation_abandoned') {
|
|
@@ -739,6 +796,7 @@ export class DirectUploads {
|
|
|
739
796
|
return;
|
|
740
797
|
}
|
|
741
798
|
p.state = 'uploading';
|
|
799
|
+
this.inFlight.uploading(p.id, this.clock());
|
|
742
800
|
await p.store.savePack(p);
|
|
743
801
|
let err;
|
|
744
802
|
this.budget.onRequest();
|
|
@@ -752,7 +810,14 @@ export class DirectUploads {
|
|
|
752
810
|
throw e;
|
|
753
811
|
err = e;
|
|
754
812
|
}
|
|
755
|
-
|
|
813
|
+
const outcome = storageOutcome(err);
|
|
814
|
+
if (outcome === 'sent' || outcome === 'unknown') {
|
|
815
|
+
p.sentAt = this.clock();
|
|
816
|
+
p.sentUnknown = outcome === 'unknown';
|
|
817
|
+
p.verifyWaited = 0;
|
|
818
|
+
this.inFlight.sent(p.id, p.sentAt);
|
|
819
|
+
}
|
|
820
|
+
switch (outcome) {
|
|
756
821
|
case 'sent':
|
|
757
822
|
p.state = 'sent';
|
|
758
823
|
break;
|
|
@@ -792,7 +857,7 @@ export class DirectUploads {
|
|
|
792
857
|
let status;
|
|
793
858
|
let res;
|
|
794
859
|
try {
|
|
795
|
-
({ status, data: res } = await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/commit`, undefined, p, signal,
|
|
860
|
+
({ status, data: res } = await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/commit`, undefined, p, signal, this.callTimeoutMs(p.items.length, COMMIT_MS_PER_OBJECT, COMMIT_TIMEOUT_MS)));
|
|
796
861
|
}
|
|
797
862
|
catch (err) {
|
|
798
863
|
if (!(err instanceof APIError))
|
|
@@ -811,13 +876,34 @@ export class DirectUploads {
|
|
|
811
876
|
return;
|
|
812
877
|
}
|
|
813
878
|
if (status === 202) {
|
|
814
|
-
|
|
815
|
-
|
|
879
|
+
// Not there yet (section 28.18): asked again after 250 ms, 500 ms, 1 s and 2 s, then every 2 s; the same bytes are
|
|
880
|
+
// sent again only 30 s after an upload that answered success, or 5 s after one whose answer was lost (or a pack
|
|
881
|
+
// resumed mid-upload). The time since is the larger of the clock's and the waits'. The waits count against
|
|
882
|
+
// uploadWait, but a pack always gets its first full cycle: a pack the network never shows ends after one sending
|
|
883
|
+
// again, kept for resumeUploads.
|
|
884
|
+
const now = this.clock();
|
|
885
|
+
if (p.sentAt === undefined) {
|
|
886
|
+
p.sentAt = now;
|
|
887
|
+
p.sentUnknown = unknown;
|
|
888
|
+
p.verifyWaited = 0;
|
|
889
|
+
}
|
|
890
|
+
const limit = p.sentUnknown ? RESEND_AFTER_LOST_MS : RESEND_AFTER_SENT_MS;
|
|
891
|
+
const since = Math.max(now - p.sentAt, p.verifyWaited);
|
|
892
|
+
if (since >= limit) {
|
|
816
893
|
p.state = 'signed'; // not there: send the same bytes again
|
|
817
894
|
p.verifies = 0;
|
|
895
|
+
p.sentAt = undefined;
|
|
818
896
|
}
|
|
819
897
|
else {
|
|
820
|
-
|
|
898
|
+
const delay = Math.min(verifyDelayMs(p.verifies), limit - since); // the last look comes as the bytes may go again
|
|
899
|
+
p.verifies++;
|
|
900
|
+
const bound = Math.max(this.client.uploadWait, limit);
|
|
901
|
+
if (p.waited + delay > bound) {
|
|
902
|
+
throw new StorageUnavailableError(`storage didn't show the pack within ${Math.round(bound / 1000)} s of waiting`);
|
|
903
|
+
}
|
|
904
|
+
p.waited += delay;
|
|
905
|
+
p.verifyWaited += delay;
|
|
906
|
+
await this.client.sleep(delay, signal);
|
|
821
907
|
}
|
|
822
908
|
await p.store.savePack(p);
|
|
823
909
|
return;
|
|
@@ -827,11 +913,19 @@ export class DirectUploads {
|
|
|
827
913
|
await p.store.dropBytes(p);
|
|
828
914
|
await p.store.savePack(p);
|
|
829
915
|
}
|
|
830
|
-
/**
|
|
916
|
+
/**
|
|
917
|
+
* A new reservation and fresh keys for the pack's items (section 17.3). A run's pack under the floor gives its files
|
|
918
|
+
* back to the planner instead (section 28.5); the old reservation is released (section 28.23).
|
|
919
|
+
*/
|
|
831
920
|
async renew(p) {
|
|
832
921
|
p.rebuilds++;
|
|
833
922
|
if (p.rebuilds > MAX_REBUILDS)
|
|
834
923
|
throw new Stop(new TeepinError(`the pack was rebuilt ${MAX_REBUILDS} times: ${p.lastError || p.state}`));
|
|
924
|
+
const stored = p.items.reduce((n, it) => n + it.stored, 0);
|
|
925
|
+
if (p.giveback && stored < this.limits.floor)
|
|
926
|
+
return this.giveBack(p);
|
|
927
|
+
if (p.serverId)
|
|
928
|
+
await this.releaseReservation(p);
|
|
835
929
|
p.lastError = p.state;
|
|
836
930
|
await p.store.dropBytes(p);
|
|
837
931
|
p.idem = uuid7();
|
|
@@ -845,9 +939,82 @@ export class DirectUploads {
|
|
|
845
939
|
p.state = 'new';
|
|
846
940
|
await p.store.savePack(p);
|
|
847
941
|
}
|
|
942
|
+
/**
|
|
943
|
+
* A resumed pack that was never signed and is under the floor: reserve is asked with its own idempotency key (and its
|
|
944
|
+
* files as journaled), so a pack an earlier run already committed answers pack_committed and is committed for its
|
|
945
|
+
* stored result. Any other answer releases what it reserved, and its files go back to the planner (28.22, point 5).
|
|
946
|
+
*/
|
|
947
|
+
async dissolvePack(p, signal) {
|
|
948
|
+
p.dissolve = false;
|
|
949
|
+
p.underFloor = 'leftover'; // not part of the idempotency digest
|
|
950
|
+
try {
|
|
951
|
+
await this.reserve(p, signal);
|
|
952
|
+
}
|
|
953
|
+
catch (err) {
|
|
954
|
+
if (signal?.aborted || !(err instanceof APIError))
|
|
955
|
+
throw err;
|
|
956
|
+
// e.g. pack_too_small from a server in enforce: nothing was committed under this key
|
|
957
|
+
}
|
|
958
|
+
if (p.state === 'committing')
|
|
959
|
+
return;
|
|
960
|
+
await this.giveBack(p);
|
|
961
|
+
}
|
|
962
|
+
/** Hands a run's pack's remaining files back to its planner (section 28.5); the pack ends as returned. */
|
|
963
|
+
async giveBack(p) {
|
|
964
|
+
if (p.serverId)
|
|
965
|
+
await this.releaseReservation(p);
|
|
966
|
+
await p.store.dropBytes(p);
|
|
967
|
+
p.state = 'returned';
|
|
968
|
+
await p.store.savePack(p);
|
|
969
|
+
const items = p.items.filter((it) => !it.result && it.error === undefined);
|
|
970
|
+
for (const it of items) {
|
|
971
|
+
it.objectId = '';
|
|
972
|
+
it.rebuilds = Math.max(it.rebuilds, p.rebuilds);
|
|
973
|
+
}
|
|
974
|
+
p.giveback(items);
|
|
975
|
+
}
|
|
976
|
+
/** Frees a given-up reservation at once (section 28.23), best effort: without the route it expires as before. */
|
|
977
|
+
async releaseReservation(p) {
|
|
978
|
+
try {
|
|
979
|
+
const { response } = await this.client.send({ method: 'POST', path: `${bucketPath(p.bucket)}/packs/${p.serverId}/release`, mode: 'safe' });
|
|
980
|
+
await response.arrayBuffer().catch(() => undefined);
|
|
981
|
+
}
|
|
982
|
+
catch {
|
|
983
|
+
// a deployment without the route, or any failure: the reservation expires on its own
|
|
984
|
+
}
|
|
985
|
+
}
|
|
848
986
|
async deliver(p, res) {
|
|
849
987
|
const when = new Date(str(res.committed_at) || NaN);
|
|
850
988
|
const backend = str(res.backend);
|
|
989
|
+
if (res.purged === true) {
|
|
990
|
+
// Committed, then every object in it deleted and the pack's row purged (TEEPIN-STORAGE-DESIGN.md 29.15): the answer
|
|
991
|
+
// has no per-object results, but each file was saved. Never uploaded again.
|
|
992
|
+
this.info(`teepin: pack ${p.serverId} was committed at ${str(res.committed_at)}; its objects have since been deleted`);
|
|
993
|
+
const saved = p.items
|
|
994
|
+
.filter((it) => !it.result && it.error === undefined)
|
|
995
|
+
.map((it) => [
|
|
996
|
+
it,
|
|
997
|
+
{
|
|
998
|
+
id: it.objectId,
|
|
999
|
+
key: it.key,
|
|
1000
|
+
size: it.size,
|
|
1001
|
+
contentType: it.contentType || undefined,
|
|
1002
|
+
metadata: it.metadata,
|
|
1003
|
+
checksumSha256: it.sha256,
|
|
1004
|
+
etag: it.sha256,
|
|
1005
|
+
partCount: 0,
|
|
1006
|
+
status: 'deleted',
|
|
1007
|
+
backend: '',
|
|
1008
|
+
createdAt: when,
|
|
1009
|
+
updatedAt: when,
|
|
1010
|
+
uploadedAt: when,
|
|
1011
|
+
},
|
|
1012
|
+
undefined,
|
|
1013
|
+
true,
|
|
1014
|
+
]);
|
|
1015
|
+
await this.done(p.store, saved);
|
|
1016
|
+
return;
|
|
1017
|
+
}
|
|
851
1018
|
const objects = res.objects ?? [];
|
|
852
1019
|
const byId = new Map(objects.map((o) => [str(o.object_id), o]));
|
|
853
1020
|
const byKey = new Map(objects.map((o) => [str(o.key), o]));
|
|
@@ -897,17 +1064,26 @@ export class DirectUploads {
|
|
|
897
1064
|
const sha = await this.digests.sha256(data);
|
|
898
1065
|
const it = new Item(key, data.length, sha, async () => data, opts.contentType, opts.metadata, Boolean(opts.ifNoneMatch), normalizeIfMatch(opts.ifMatch));
|
|
899
1066
|
it.done = new Deferred();
|
|
900
|
-
this.
|
|
901
|
-
|
|
1067
|
+
let arrivals = this.waiting.get(bucket);
|
|
1068
|
+
if (!arrivals) {
|
|
1069
|
+
arrivals = new Arrivals();
|
|
1070
|
+
this.waiting.set(bucket, arrivals);
|
|
1071
|
+
}
|
|
1072
|
+
arrivals.add(it, it.key, it.stored, it.request, this.clock());
|
|
902
1073
|
const wait = this.client.uploadWait;
|
|
903
|
-
|
|
1074
|
+
// The observe time (28.25) is the timing rule's own wait, not one for a slot: it never counts against uploadWait (a
|
|
1075
|
+
// lone save with uploadWait 0 still goes).
|
|
1076
|
+
const deadline = Date.now() + wait + this.limits.observeMs;
|
|
904
1077
|
const leave = () => {
|
|
905
|
-
const
|
|
906
|
-
if (
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
1078
|
+
const a = this.waiting.get(bucket);
|
|
1079
|
+
if (!a || it.picked)
|
|
1080
|
+
return false;
|
|
1081
|
+
a.remove([it]);
|
|
1082
|
+
if (!a.size)
|
|
1083
|
+
this.waiting.delete(bucket);
|
|
1084
|
+
if (!this.waiting.size)
|
|
1085
|
+
this.limit.setPriority(false); // no save is left to hold a run up
|
|
1086
|
+
return true;
|
|
911
1087
|
};
|
|
912
1088
|
try {
|
|
913
1089
|
while (!it.picked) {
|
|
@@ -923,8 +1099,11 @@ export class DirectUploads {
|
|
|
923
1099
|
break;
|
|
924
1100
|
let timer;
|
|
925
1101
|
try {
|
|
926
|
-
// A timer past 2^31 - 1 ms fires at once in Node and browsers, so a very long uploadWait waits in slices
|
|
927
|
-
|
|
1102
|
+
// A timer past 2^31 - 1 ms fires at once in Node and browsers, so a very long uploadWait waits in slices; the
|
|
1103
|
+
// timing rule's next decision comes sooner.
|
|
1104
|
+
let slice = Math.min(left, MAX_TIMER_MS);
|
|
1105
|
+
if (this.nextDecision !== undefined)
|
|
1106
|
+
slice = Math.min(slice, Math.max(1, this.nextDecision - this.clock()));
|
|
928
1107
|
await abortable(Promise.race([woken.promise, new Promise((r) => (timer = setTimeout(r, slice)))]), signal);
|
|
929
1108
|
}
|
|
930
1109
|
finally {
|
|
@@ -945,21 +1124,67 @@ export class DirectUploads {
|
|
|
945
1124
|
throw new TeepinError(`${key}: no result`);
|
|
946
1125
|
return it.result;
|
|
947
1126
|
}
|
|
948
|
-
/**
|
|
1127
|
+
/**
|
|
1128
|
+
* Sends the packs section 28's timing rule allows, oldest bucket first, each when a slot is free and the memory cap
|
|
1129
|
+
* allows, and notes when the rule next needs a decision. While a pack would go but has no slot, a run's next pack
|
|
1130
|
+
* waits for it (priority); a pack that is only gathering holds no run up. Replaced in tests.
|
|
1131
|
+
*/
|
|
949
1132
|
dispatch() {
|
|
950
|
-
|
|
951
|
-
const
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
const
|
|
955
|
-
|
|
956
|
-
this.limit.setPriority(this.queue.length > 0);
|
|
957
|
-
for (const it of batch) {
|
|
1133
|
+
for (;;) {
|
|
1134
|
+
const chosen = this.choose();
|
|
1135
|
+
if (!chosen)
|
|
1136
|
+
return;
|
|
1137
|
+
const [bucket, plan, reason] = chosen;
|
|
1138
|
+
for (const it of plan.pack) {
|
|
958
1139
|
it.picked = true;
|
|
959
|
-
it.wake?.resolve(); // its caller may be waiting
|
|
1140
|
+
it.wake?.resolve(); // its caller may be waiting: its file has a pack now
|
|
960
1141
|
}
|
|
961
|
-
|
|
962
|
-
|
|
1142
|
+
const p = new Pack(bucket, plan.pack, this.memory);
|
|
1143
|
+
p.underFloor = reason;
|
|
1144
|
+
p.inflight = plan.stored;
|
|
1145
|
+
this.inFlight.add(p.id, bucket, plan.stored);
|
|
1146
|
+
void this.runShared(p);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
/** The next pack to send and its reason (a slot taken for it), or undefined; sets the next decision and priority. */
|
|
1150
|
+
choose() {
|
|
1151
|
+
const now = this.clock();
|
|
1152
|
+
const speed = this.speed.bytesPerSecond;
|
|
1153
|
+
const requestBytes = this.speed.requestBytes();
|
|
1154
|
+
const [limits, slow] = atSpeed(this.limits, requestBytes);
|
|
1155
|
+
const [choices, next] = schedule(this.waiting, now, limits, (a, tailCap) => planPack(a.items(), itemStored, itemKey, itemRequest, limits, tailCap !== undefined, tailCap), (bucket, by) => this.inFlight.coming(bucket, by, now, speed, limits.observeMs, (t) => this.waiting.get(bucket)?.arrivedSince(t) ?? 0), this.limit.runsWaiting(), slow ? 0 : absorbCap(this.limits, requestBytes));
|
|
1156
|
+
this.nextDecision = next;
|
|
1157
|
+
let blocked = false;
|
|
1158
|
+
for (const c of choices) {
|
|
1159
|
+
const p = c.plan;
|
|
1160
|
+
if (this.inflight > 0 && this.inflight + p.stored > MEMORY_CAP) {
|
|
1161
|
+
blocked = true;
|
|
1162
|
+
continue;
|
|
1163
|
+
}
|
|
1164
|
+
if (!this.limit.tryAcquire()) {
|
|
1165
|
+
blocked = true;
|
|
1166
|
+
continue;
|
|
1167
|
+
}
|
|
1168
|
+
const a = this.waiting.get(c.bucket);
|
|
1169
|
+
a.remove(p.pack);
|
|
1170
|
+
if (!a.size)
|
|
1171
|
+
this.waiting.delete(c.bucket);
|
|
1172
|
+
this.inflight += p.stored;
|
|
1173
|
+
this.limit.setPriority(blocked);
|
|
1174
|
+
return [c.bucket, p, this.reasonFor(p, c.decision.reason, slow)];
|
|
1175
|
+
}
|
|
1176
|
+
this.limit.setPriority(blocked);
|
|
1177
|
+
return undefined;
|
|
1178
|
+
}
|
|
1179
|
+
/** A pack's reason for being under the floor (section 28.9), or undefined at or over it. */
|
|
1180
|
+
reasonFor(p, reason, slow) {
|
|
1181
|
+
if (p.stored >= this.limits.floor)
|
|
1182
|
+
return undefined;
|
|
1183
|
+
if (slow)
|
|
1184
|
+
return 'slow_link';
|
|
1185
|
+
if (p.state === 'request_limited')
|
|
1186
|
+
return 'request_limit';
|
|
1187
|
+
return reason ?? 'leftover';
|
|
963
1188
|
}
|
|
964
1189
|
async runShared(p) {
|
|
965
1190
|
let outcome = 'failed';
|
|
@@ -973,8 +1198,13 @@ export class DirectUploads {
|
|
|
973
1198
|
it.error = err; // every caller gets its answer
|
|
974
1199
|
}
|
|
975
1200
|
finally {
|
|
1201
|
+
// Out of the packs in flight, into its grace time (28.25), before its slot is released. Its callers were woken as
|
|
1202
|
+
// each item was delivered: one that saved again meanwhile counted twice until here, which can only hold a pack, and
|
|
1203
|
+
// the release below has it decided again.
|
|
1204
|
+
this.inFlight.remove(p.id, this.clock(), p.state === 'committed');
|
|
976
1205
|
for (const it of p.items)
|
|
977
1206
|
it.done?.resolve();
|
|
1207
|
+
this.inflight -= p.inflight;
|
|
978
1208
|
this.release(outcome);
|
|
979
1209
|
}
|
|
980
1210
|
}
|
|
@@ -1195,6 +1425,7 @@ export class DirectUploads {
|
|
|
1195
1425
|
}
|
|
1196
1426
|
async sendRun(state, onProgress, partSize, signal) {
|
|
1197
1427
|
const { bucket, store, place, items } = state;
|
|
1428
|
+
items.forEach((it, i) => (it.order = i));
|
|
1198
1429
|
const lim = this.packObjectLimit();
|
|
1199
1430
|
// A file whose upload in parts a crash left open goes on in parts, whatever the speed now (section 22.7).
|
|
1200
1431
|
const inParts = new Set(store.journal
|
|
@@ -1203,7 +1434,7 @@ export class DirectUploads {
|
|
|
1203
1434
|
.map((r) => str(r.file_id)));
|
|
1204
1435
|
const large = items.filter((it) => it.size > lim || (it.size > 0 && it.fileId !== undefined && inParts.has(it.fileId)));
|
|
1205
1436
|
const chosen = new Set(large);
|
|
1206
|
-
|
|
1437
|
+
let small = items.filter((it) => it.size > 0 && !chosen.has(it));
|
|
1207
1438
|
// The small files are packed as slots come free, each pack at the speed measured by then (section 22.7).
|
|
1208
1439
|
const packs = [...state.packs];
|
|
1209
1440
|
let total = 0;
|
|
@@ -1255,32 +1486,56 @@ export class DirectUploads {
|
|
|
1255
1486
|
await this.abandon(store, place); // the last task of an interrupted run releases it
|
|
1256
1487
|
}
|
|
1257
1488
|
};
|
|
1258
|
-
|
|
1489
|
+
// Files a pack gives back (a changed file's pack, a renewal under the floor) are planned again with the rest, their
|
|
1490
|
+
// place in the run kept (section 28.5).
|
|
1491
|
+
let back = [];
|
|
1492
|
+
let tasks = [];
|
|
1259
1493
|
try {
|
|
1260
1494
|
const start = (p) => {
|
|
1495
|
+
p.giveback = (returned) => back.push(...returned);
|
|
1261
1496
|
closer.started();
|
|
1262
1497
|
tasks.push(go(p).catch(failed));
|
|
1263
1498
|
};
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1499
|
+
// A first round dissolves the resumed packs under the floor (section 28.22, point 5), so their files are planned
|
|
1500
|
+
// with the queued ones; the other resumed packs and the queued files wait for it.
|
|
1501
|
+
const dissolving = packs.filter((p) => p.dissolve);
|
|
1502
|
+
for (const group of [dissolving, packs.filter((p) => !p.dissolve)]) {
|
|
1503
|
+
for (const p of group) {
|
|
1504
|
+
await this.limit.acquire(ctl.signal);
|
|
1505
|
+
if (failure) {
|
|
1506
|
+
this.release('failed');
|
|
1507
|
+
throw failure.err;
|
|
1508
|
+
}
|
|
1509
|
+
start(p);
|
|
1269
1510
|
}
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1511
|
+
if (group === dissolving && dissolving.length) {
|
|
1512
|
+
await abortable(Promise.all(tasks), ctl.signal);
|
|
1513
|
+
if (failure)
|
|
1514
|
+
throw failure.err;
|
|
1515
|
+
tasks = [];
|
|
1516
|
+
small = [...back, ...small].sort((a, b) => a.order - b.order);
|
|
1517
|
+
back = [];
|
|
1277
1518
|
}
|
|
1278
|
-
let chunk;
|
|
1279
|
-
[chunk, rest] = nextPack(rest, (i) => i.size, (i) => i.key, this.packTarget(), this.sizes.packMaxObjects);
|
|
1280
|
-
const p = new Pack(bucket, chunk, store);
|
|
1281
|
-
packs.push(p);
|
|
1282
|
-
start(p);
|
|
1283
1519
|
}
|
|
1520
|
+
// A pack for each slot that comes free, planned at the speed measured by then, the run's tail joining its last pack.
|
|
1521
|
+
const planSmall = async (files) => {
|
|
1522
|
+
for (let rest = files; rest.length;) {
|
|
1523
|
+
await this.limit.acquire(ctl.signal);
|
|
1524
|
+
if (failure) {
|
|
1525
|
+
this.release('failed');
|
|
1526
|
+
throw failure.err;
|
|
1527
|
+
}
|
|
1528
|
+
const [limits, slow] = atSpeed(this.limits, this.speed.requestBytes());
|
|
1529
|
+
const plan = planPack(rest, itemStored, itemKey, itemRequest, limits, true);
|
|
1530
|
+
rest = plan.rest;
|
|
1531
|
+
const p = new Pack(bucket, plan.pack, store);
|
|
1532
|
+
p.underFloor = this.reasonFor(plan, 'leftover', slow);
|
|
1533
|
+
p.rebuilds = Math.max(0, ...plan.pack.map((it) => it.rebuilds));
|
|
1534
|
+
packs.push(p);
|
|
1535
|
+
start(p);
|
|
1536
|
+
}
|
|
1537
|
+
};
|
|
1538
|
+
await planSmall(small);
|
|
1284
1539
|
for (const it of large) {
|
|
1285
1540
|
if (stop.length) {
|
|
1286
1541
|
await this.done(store, [[it, undefined, stop[0], false]]);
|
|
@@ -1306,10 +1561,19 @@ export class DirectUploads {
|
|
|
1306
1561
|
throw err;
|
|
1307
1562
|
}
|
|
1308
1563
|
}
|
|
1309
|
-
// A task that fails stops the run at once (its controller aborts), as joining it first would.
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1564
|
+
// A task that fails stops the run at once (its controller aborts), as joining it first would. What packs gave back
|
|
1565
|
+
// is planned again with its place in the run kept, until none gives any back.
|
|
1566
|
+
for (;;) {
|
|
1567
|
+
await abortable(Promise.all(tasks), ctl.signal);
|
|
1568
|
+
if (failure)
|
|
1569
|
+
throw failure.err;
|
|
1570
|
+
const again = back.sort((a, b) => a.order - b.order);
|
|
1571
|
+
back = [];
|
|
1572
|
+
if (!again.length)
|
|
1573
|
+
break;
|
|
1574
|
+
tasks = [];
|
|
1575
|
+
await planSmall(again);
|
|
1576
|
+
}
|
|
1313
1577
|
}
|
|
1314
1578
|
catch (err) {
|
|
1315
1579
|
// Interrupted, or failed: every pack still under way is stopped and waited for (up to the cap); then the run's
|
|
@@ -1494,7 +1758,7 @@ export class DirectUploads {
|
|
|
1494
1758
|
const packs = [];
|
|
1495
1759
|
for (const r of j.rows('packs')) {
|
|
1496
1760
|
const pid = str(r._id);
|
|
1497
|
-
if (r.state === 'committed' || r.state === 'failed')
|
|
1761
|
+
if (r.state === 'committed' || r.state === 'failed' || r.state === 'returned')
|
|
1498
1762
|
continue;
|
|
1499
1763
|
const its = byPack.get(pid);
|
|
1500
1764
|
byPack.delete(pid);
|
|
@@ -1513,10 +1777,18 @@ export class DirectUploads {
|
|
|
1513
1777
|
p.attempts = int(r.attempts);
|
|
1514
1778
|
if (p.state === 'uploading')
|
|
1515
1779
|
p.state = 'unknown'; // the PUT may have landed: commit first
|
|
1780
|
+
// Not yet signed and under the floor (section 28.22, point 5): its files are planned again, once its idempotency
|
|
1781
|
+
// key has shown it wasn't committed (dissolvePack).
|
|
1782
|
+
p.dissolve = UNSIGNED.has(p.state) && its.reduce((n, it) => n + it.stored, 0) < this.limits.floor;
|
|
1516
1783
|
packs.push(p);
|
|
1517
1784
|
}
|
|
1518
1785
|
for (const its of byPack.values())
|
|
1519
1786
|
queued.push(...its); // packed into a pack that's gone: queue them again
|
|
1787
|
+
// Dissolving gains nothing when there is nothing to merge with: a lone small pack would be planned again as the same
|
|
1788
|
+
// leftover, and carrying on under its own idempotency key keeps a restored older journal from sending files twice.
|
|
1789
|
+
if (packs.filter((p) => p.dissolve).length + (queued.length ? 1 : 0) < 2)
|
|
1790
|
+
for (const p of packs)
|
|
1791
|
+
p.dissolve = false;
|
|
1520
1792
|
await this.sweepOrphans(place, j, packs);
|
|
1521
1793
|
return { state: { bucket, store, place, items: queued, packs, results }, changed };
|
|
1522
1794
|
}
|