@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.
@@ -32,6 +32,7 @@ const bytes_js_1 = require("./bytes.js");
32
32
  const engine_js_1 = require("./engine.js");
33
33
  const common_js_1 = require("./common.js");
34
34
  const hpke = __importStar(require("./hpke.js"));
35
+ const packing_js_1 = require("./packing.js");
35
36
  const journal_js_1 = require("./journal.js");
36
37
  const large_js_1 = require("./large.js");
37
38
  const sha256_js_1 = require("./sha256.js");
@@ -170,6 +171,11 @@ class Item {
170
171
  wake;
171
172
  /** The pack and object id its file row last journaled. */
172
173
  journaled = '';
174
+ /** A run's place for the file (items given back to the planner keep it). */
175
+ order = 0;
176
+ /** Rebuilds of the packs it was in, carried when a pack gives it back, so MAX_REBUILDS holds. */
177
+ rebuilds = 0;
178
+ reqBytes = 0;
173
179
  contentType;
174
180
  metadata;
175
181
  ifMatch;
@@ -185,6 +191,16 @@ class Item {
185
191
  this.metadata = { ...(metadata ?? {}) };
186
192
  this.ifMatch = ifMatch || '';
187
193
  }
194
+ /** Its bytes in a pack: its needle and TWS1 bytes. */
195
+ get stored() {
196
+ return tws1.NEEDLE_HEADER_SIZE + tws1.storedSize(this.size);
197
+ }
198
+ /** The bytes it adds to a reserve body (section 28.17): its JSON object and a separator. */
199
+ get request() {
200
+ if (!this.reqBytes)
201
+ this.reqBytes = new TextEncoder().encode(JSON.stringify(this.reserveJSON())).length + 2;
202
+ return this.reqBytes;
203
+ }
188
204
  reserveJSON() {
189
205
  const o = { key: this.key, size: this.size, sha256: this.sha256 };
190
206
  if (this.contentType)
@@ -199,6 +215,11 @@ class Item {
199
215
  }
200
216
  }
201
217
  exports.Item = Item;
218
+ /** A pack's states before its first signature: its bytes can't be on the storage network yet. */
219
+ const UNSIGNED = new Set(['new', 'reserved', 'built', 'conflict', 'bad']);
220
+ const itemStored = (it) => it.stored;
221
+ const itemKey = (it) => it.key;
222
+ const itemRequest = (it) => it.request;
202
223
  /** One pack's state, kept in its store (a run's journal, or memory). */
203
224
  class Pack {
204
225
  bucket;
@@ -222,6 +243,18 @@ class Pack {
222
243
  waited = 0;
223
244
  throttled = false;
224
245
  lastError = '';
246
+ /** The reason for a pack under the floor (section 28.9), sent with its reserve. */
247
+ underFloor;
248
+ /** Its bytes counted against MEMORY_CAP while in flight (putObject's packs). */
249
+ inflight = 0;
250
+ /** When its upload ended (in memory only), whether that answer was lost, and the checking waits since (28.18). */
251
+ sentAt;
252
+ sentUnknown = false;
253
+ verifyWaited = 0;
254
+ /** A run's packs give their files back to its planner instead of renewing under the floor (28.5). */
255
+ giveback;
256
+ /** Resumed, unsigned and under the floor (28.22 point 5): its idempotency key is asked first. */
257
+ dissolve = false;
225
258
  constructor(bucket, items, store, id) {
226
259
  this.bucket = bucket;
227
260
  this.items = items;
@@ -399,11 +432,12 @@ class DirectUploads {
399
432
  sizes = {
400
433
  ...engine_js_1.PART_RULES,
401
434
  packObjectLimit: engine_js_1.PACK_OBJECT_LIMIT,
402
- packTarget: engine_js_1.PACK_TARGET_BYTES,
403
- packMinTarget: engine_js_1.PACK_MIN_TARGET,
404
- packMaxObjects: engine_js_1.PACK_MAX_OBJECTS,
405
435
  streamConcurrency: engine_js_1.STREAM_CONCURRENCY,
406
436
  };
437
+ /** The pack sizes and timings (section 28): built in, then the server's from each reservation. Tests set them. */
438
+ limits = { ...packing_js_1.DEFAULT_LIMITS };
439
+ /** The timing rule's monotonic clock in milliseconds (tests replace it). */
440
+ clock = () => performance.now();
407
441
  /**
408
442
  * The measured upload speed to the storage network: shrinks packs and new uploads' parts on a slow link (section
409
443
  * 22.7). Tests set it with speed.set.
@@ -414,6 +448,14 @@ class DirectUploads {
414
448
  * slower, twice its time at that speed (section 22.7). Tests shorten it.
415
449
  */
416
450
  putTimeout = { minMs: 30000, bytesPerSecond: 65536 };
451
+ /**
452
+ * A pack call's timeout (29.15): the server's time for `objects` objects, scaled, plus sending `body` (none for a
453
+ * commit) at the speed measured so far; never under `floorMs`.
454
+ */
455
+ callTimeoutMs(objects, perObjectMs, floorMs, body) {
456
+ const n = body === undefined ? 0 : new TextEncoder().encode(JSON.stringify(body)).length;
457
+ return (0, engine_js_1.scaledTimeoutMs)(objects, perObjectMs, floorMs, (0, engine_js_1.bodyTransferMs)(n, this.speed.bytesPerSecond, this.putTimeout.bytesPerSecond));
458
+ }
417
459
  /** How long a stopping step waits for its tasks, and a cleanup call may take (ms). */
418
460
  cleanupMs = exports.CLEANUP_MS;
419
461
  digests = exports.webDigests;
@@ -422,8 +464,17 @@ class DirectUploads {
422
464
  host;
423
465
  /** Where warnings go: a run removed by cleanUploads, a large state directory. */
424
466
  warn = (m) => console.warn(m);
467
+ /** Where a line worth knowing but not a warning goes (a purged pack's commit, 29.15); tests replace it. */
468
+ info = (m) => console.info(m);
425
469
  accountId;
426
- queue = [];
470
+ /** putObject's waiting files, per bucket, with their running totals (section 28.17). */
471
+ waiting = new Map();
472
+ /** When the timing rule next needs a decision, if it does. */
473
+ nextDecision;
474
+ /** putObject's pack bytes in flight, against MEMORY_CAP. */
475
+ inflight = 0;
476
+ /** putObject's packs in flight and when each is expected back (section 28.25). */
477
+ inFlight = new packing_js_1.InFlight();
427
478
  /** Wake-ups of putObject calls waiting for a slot: resolved when one comes free. */
428
479
  slotWaiters = [];
429
480
  memory = new PackStore();
@@ -501,10 +552,6 @@ class DirectUploads {
501
552
  packObjectLimit() {
502
553
  return (0, engine_js_1.packObjectLimit)(this.speed.requestBytes(), this.sizes.packObjectLimit, this.sizes.minBytes);
503
554
  }
504
- /** The next pack's target size, at the speed measured so far. */
505
- packTarget() {
506
- return (0, engine_js_1.packTarget)(this.speed.requestBytes(), this.sizes.packTarget, this.sizes.packMinTarget);
507
- }
508
555
  // --- shared plumbing ----------------------------------------------------------
509
556
  /** Where runs keep their state, when it's on disk. */
510
557
  get runHost() {
@@ -592,7 +639,9 @@ class DirectUploads {
592
639
  */
593
640
  async drive(p, signal) {
594
641
  try {
595
- while (p.state !== 'committed' && p.state !== 'failed') {
642
+ if (p.dissolve)
643
+ await this.dissolvePack(p, signal);
644
+ while (p.state !== 'committed' && p.state !== 'failed' && p.state !== 'returned') {
596
645
  signal?.throwIfAborted();
597
646
  switch (p.state) {
598
647
  case 'new':
@@ -645,9 +694,11 @@ class DirectUploads {
645
694
  async reserve(p, signal) {
646
695
  await p.store.savePack(p); // the idempotency key, before the call
647
696
  const body = { idempotency_key: p.idem, objects: p.items.map((it) => it.reserveJSON()) };
697
+ if (p.underFloor)
698
+ body.under_floor = p.underFloor;
648
699
  let res;
649
700
  try {
650
- res = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs`, body, p, signal)).data;
701
+ res = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs`, body, p, signal, this.callTimeoutMs(p.items.length, engine_js_1.RESERVE_MS_PER_OBJECT, this.client.timeout, body))).data;
651
702
  }
652
703
  catch (err) {
653
704
  if (err instanceof errors_js_1.APIError && err.code === 'reservation_abandoned') {
@@ -665,6 +716,9 @@ class DirectUploads {
665
716
  throw err;
666
717
  }
667
718
  await this.checkKey(res.account_key);
719
+ const limits = (0, packing_js_1.limitsFromJSON)(res.limits);
720
+ if (limits)
721
+ this.limits = limits;
668
722
  const ids = new Map((res.objects ?? []).map((o) => [str(o.key), str(o.object_id)]));
669
723
  for (const it of p.items)
670
724
  it.objectId = ids.get(it.key) ?? '';
@@ -680,6 +734,9 @@ class DirectUploads {
680
734
  // The reservation names files that changed: the rest go under a new one.
681
735
  const gone = new Set(built.changed.map(([it]) => it));
682
736
  p.items = p.items.filter((it) => !gone.has(it));
737
+ // A run's other files go back to its planner, so they never go as a smaller pack (section 28.5).
738
+ if (p.giveback && p.items.length)
739
+ return this.giveBack(p);
683
740
  p.state = p.items.length ? 'conflict' : 'failed';
684
741
  p.rebuilds--; // not the pack's fault
685
742
  await p.store.savePack(p);
@@ -753,7 +810,7 @@ class DirectUploads {
753
810
  async sign(p, signal) {
754
811
  let signed;
755
812
  try {
756
- signed = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/sign`, p.signBody, p, signal)).data;
813
+ signed = (await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/sign`, p.signBody, p, signal, this.callTimeoutMs(p.items.length, engine_js_1.SIGN_MS_PER_OBJECT, this.client.timeout, p.signBody))).data;
757
814
  }
758
815
  catch (err) {
759
816
  if (err instanceof errors_js_1.APIError && err.code === 'reservation_abandoned') {
@@ -783,6 +840,7 @@ class DirectUploads {
783
840
  return;
784
841
  }
785
842
  p.state = 'uploading';
843
+ this.inFlight.uploading(p.id, this.clock());
786
844
  await p.store.savePack(p);
787
845
  let err;
788
846
  this.budget.onRequest();
@@ -796,7 +854,14 @@ class DirectUploads {
796
854
  throw e;
797
855
  err = e;
798
856
  }
799
- switch ((0, engine_js_1.storageOutcome)(err)) {
857
+ const outcome = (0, engine_js_1.storageOutcome)(err);
858
+ if (outcome === 'sent' || outcome === 'unknown') {
859
+ p.sentAt = this.clock();
860
+ p.sentUnknown = outcome === 'unknown';
861
+ p.verifyWaited = 0;
862
+ this.inFlight.sent(p.id, p.sentAt);
863
+ }
864
+ switch (outcome) {
800
865
  case 'sent':
801
866
  p.state = 'sent';
802
867
  break;
@@ -836,7 +901,7 @@ class DirectUploads {
836
901
  let status;
837
902
  let res;
838
903
  try {
839
- ({ status, data: res } = await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/commit`, undefined, p, signal, 70000));
904
+ ({ status, data: res } = await this.control(`POST`, `${bucketPath(p.bucket)}/packs/${p.serverId}/commit`, undefined, p, signal, this.callTimeoutMs(p.items.length, engine_js_1.COMMIT_MS_PER_OBJECT, engine_js_1.COMMIT_TIMEOUT_MS)));
840
905
  }
841
906
  catch (err) {
842
907
  if (!(err instanceof errors_js_1.APIError))
@@ -855,13 +920,34 @@ class DirectUploads {
855
920
  return;
856
921
  }
857
922
  if (status === 202) {
858
- p.verifies++;
859
- if (unknown || p.verifies > engine_js_1.COMMIT_VERIFY_TRIES) {
923
+ // 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
924
+ // sent again only 30 s after an upload that answered success, or 5 s after one whose answer was lost (or a pack
925
+ // resumed mid-upload). The time since is the larger of the clock's and the waits'. The waits count against
926
+ // uploadWait, but a pack always gets its first full cycle: a pack the network never shows ends after one sending
927
+ // again, kept for resumeUploads.
928
+ const now = this.clock();
929
+ if (p.sentAt === undefined) {
930
+ p.sentAt = now;
931
+ p.sentUnknown = unknown;
932
+ p.verifyWaited = 0;
933
+ }
934
+ const limit = p.sentUnknown ? engine_js_1.RESEND_AFTER_LOST_MS : engine_js_1.RESEND_AFTER_SENT_MS;
935
+ const since = Math.max(now - p.sentAt, p.verifyWaited);
936
+ if (since >= limit) {
860
937
  p.state = 'signed'; // not there: send the same bytes again
861
938
  p.verifies = 0;
939
+ p.sentAt = undefined;
862
940
  }
863
941
  else {
864
- await this.client.sleep(1000, signal);
942
+ const delay = Math.min((0, engine_js_1.verifyDelayMs)(p.verifies), limit - since); // the last look comes as the bytes may go again
943
+ p.verifies++;
944
+ const bound = Math.max(this.client.uploadWait, limit);
945
+ if (p.waited + delay > bound) {
946
+ throw new errors_js_1.StorageUnavailableError(`storage didn't show the pack within ${Math.round(bound / 1000)} s of waiting`);
947
+ }
948
+ p.waited += delay;
949
+ p.verifyWaited += delay;
950
+ await this.client.sleep(delay, signal);
865
951
  }
866
952
  await p.store.savePack(p);
867
953
  return;
@@ -871,11 +957,19 @@ class DirectUploads {
871
957
  await p.store.dropBytes(p);
872
958
  await p.store.savePack(p);
873
959
  }
874
- /** A new reservation and fresh keys for the pack's items (section 17.3). */
960
+ /**
961
+ * A new reservation and fresh keys for the pack's items (section 17.3). A run's pack under the floor gives its files
962
+ * back to the planner instead (section 28.5); the old reservation is released (section 28.23).
963
+ */
875
964
  async renew(p) {
876
965
  p.rebuilds++;
877
966
  if (p.rebuilds > engine_js_1.MAX_REBUILDS)
878
967
  throw new Stop(new errors_js_1.TeepinError(`the pack was rebuilt ${engine_js_1.MAX_REBUILDS} times: ${p.lastError || p.state}`));
968
+ const stored = p.items.reduce((n, it) => n + it.stored, 0);
969
+ if (p.giveback && stored < this.limits.floor)
970
+ return this.giveBack(p);
971
+ if (p.serverId)
972
+ await this.releaseReservation(p);
879
973
  p.lastError = p.state;
880
974
  await p.store.dropBytes(p);
881
975
  p.idem = (0, common_js_1.uuid7)();
@@ -889,9 +983,82 @@ class DirectUploads {
889
983
  p.state = 'new';
890
984
  await p.store.savePack(p);
891
985
  }
986
+ /**
987
+ * A resumed pack that was never signed and is under the floor: reserve is asked with its own idempotency key (and its
988
+ * files as journaled), so a pack an earlier run already committed answers pack_committed and is committed for its
989
+ * stored result. Any other answer releases what it reserved, and its files go back to the planner (28.22, point 5).
990
+ */
991
+ async dissolvePack(p, signal) {
992
+ p.dissolve = false;
993
+ p.underFloor = 'leftover'; // not part of the idempotency digest
994
+ try {
995
+ await this.reserve(p, signal);
996
+ }
997
+ catch (err) {
998
+ if (signal?.aborted || !(err instanceof errors_js_1.APIError))
999
+ throw err;
1000
+ // e.g. pack_too_small from a server in enforce: nothing was committed under this key
1001
+ }
1002
+ if (p.state === 'committing')
1003
+ return;
1004
+ await this.giveBack(p);
1005
+ }
1006
+ /** Hands a run's pack's remaining files back to its planner (section 28.5); the pack ends as returned. */
1007
+ async giveBack(p) {
1008
+ if (p.serverId)
1009
+ await this.releaseReservation(p);
1010
+ await p.store.dropBytes(p);
1011
+ p.state = 'returned';
1012
+ await p.store.savePack(p);
1013
+ const items = p.items.filter((it) => !it.result && it.error === undefined);
1014
+ for (const it of items) {
1015
+ it.objectId = '';
1016
+ it.rebuilds = Math.max(it.rebuilds, p.rebuilds);
1017
+ }
1018
+ p.giveback(items);
1019
+ }
1020
+ /** Frees a given-up reservation at once (section 28.23), best effort: without the route it expires as before. */
1021
+ async releaseReservation(p) {
1022
+ try {
1023
+ const { response } = await this.client.send({ method: 'POST', path: `${bucketPath(p.bucket)}/packs/${p.serverId}/release`, mode: 'safe' });
1024
+ await response.arrayBuffer().catch(() => undefined);
1025
+ }
1026
+ catch {
1027
+ // a deployment without the route, or any failure: the reservation expires on its own
1028
+ }
1029
+ }
892
1030
  async deliver(p, res) {
893
1031
  const when = new Date(str(res.committed_at) || NaN);
894
1032
  const backend = str(res.backend);
1033
+ if (res.purged === true) {
1034
+ // Committed, then every object in it deleted and the pack's row purged (TEEPIN-STORAGE-DESIGN.md 29.15): the answer
1035
+ // has no per-object results, but each file was saved. Never uploaded again.
1036
+ this.info(`teepin: pack ${p.serverId} was committed at ${str(res.committed_at)}; its objects have since been deleted`);
1037
+ const saved = p.items
1038
+ .filter((it) => !it.result && it.error === undefined)
1039
+ .map((it) => [
1040
+ it,
1041
+ {
1042
+ id: it.objectId,
1043
+ key: it.key,
1044
+ size: it.size,
1045
+ contentType: it.contentType || undefined,
1046
+ metadata: it.metadata,
1047
+ checksumSha256: it.sha256,
1048
+ etag: it.sha256,
1049
+ partCount: 0,
1050
+ status: 'deleted',
1051
+ backend: '',
1052
+ createdAt: when,
1053
+ updatedAt: when,
1054
+ uploadedAt: when,
1055
+ },
1056
+ undefined,
1057
+ true,
1058
+ ]);
1059
+ await this.done(p.store, saved);
1060
+ return;
1061
+ }
895
1062
  const objects = res.objects ?? [];
896
1063
  const byId = new Map(objects.map((o) => [str(o.object_id), o]));
897
1064
  const byKey = new Map(objects.map((o) => [str(o.key), o]));
@@ -941,17 +1108,26 @@ class DirectUploads {
941
1108
  const sha = await this.digests.sha256(data);
942
1109
  const it = new Item(key, data.length, sha, async () => data, opts.contentType, opts.metadata, Boolean(opts.ifNoneMatch), normalizeIfMatch(opts.ifMatch));
943
1110
  it.done = new common_js_1.Deferred();
944
- this.queue.push([bucket, it]);
945
- this.limit.setPriority(true);
1111
+ let arrivals = this.waiting.get(bucket);
1112
+ if (!arrivals) {
1113
+ arrivals = new packing_js_1.Arrivals();
1114
+ this.waiting.set(bucket, arrivals);
1115
+ }
1116
+ arrivals.add(it, it.key, it.stored, it.request, this.clock());
946
1117
  const wait = this.client.uploadWait;
947
- const deadline = Date.now() + wait;
1118
+ // The observe time (28.25) is the timing rule's own wait, not one for a slot: it never counts against uploadWait (a
1119
+ // lone save with uploadWait 0 still goes).
1120
+ const deadline = Date.now() + wait + this.limits.observeMs;
948
1121
  const leave = () => {
949
- const queued = this.queue.some(([, i]) => i === it);
950
- if (queued) {
951
- this.queue = this.queue.filter(([, i]) => i !== it);
952
- this.limit.setPriority(this.queue.length > 0);
953
- }
954
- return queued;
1122
+ const a = this.waiting.get(bucket);
1123
+ if (!a || it.picked)
1124
+ return false;
1125
+ a.remove([it]);
1126
+ if (!a.size)
1127
+ this.waiting.delete(bucket);
1128
+ if (!this.waiting.size)
1129
+ this.limit.setPriority(false); // no save is left to hold a run up
1130
+ return true;
955
1131
  };
956
1132
  try {
957
1133
  while (!it.picked) {
@@ -967,8 +1143,11 @@ class DirectUploads {
967
1143
  break;
968
1144
  let timer;
969
1145
  try {
970
- // A timer past 2^31 - 1 ms fires at once in Node and browsers, so a very long uploadWait waits in slices.
971
- const slice = Math.min(left, MAX_TIMER_MS);
1146
+ // A timer past 2^31 - 1 ms fires at once in Node and browsers, so a very long uploadWait waits in slices; the
1147
+ // timing rule's next decision comes sooner.
1148
+ let slice = Math.min(left, MAX_TIMER_MS);
1149
+ if (this.nextDecision !== undefined)
1150
+ slice = Math.min(slice, Math.max(1, this.nextDecision - this.clock()));
972
1151
  await (0, common_js_1.abortable)(Promise.race([woken.promise, new Promise((r) => (timer = setTimeout(r, slice)))]), signal);
973
1152
  }
974
1153
  finally {
@@ -989,21 +1168,67 @@ class DirectUploads {
989
1168
  throw new errors_js_1.TeepinError(`${key}: no result`);
990
1169
  return it.result;
991
1170
  }
992
- /** Forms a pack from what's waiting whenever a send slot is free. Replaced in tests. */
1171
+ /**
1172
+ * Sends the packs section 28's timing rule allows, oldest bucket first, each when a slot is free and the memory cap
1173
+ * allows, and notes when the rule next needs a decision. While a pack would go but has no slot, a run's next pack
1174
+ * waits for it (priority); a pack that is only gathering holds no run up. Replaced in tests.
1175
+ */
993
1176
  dispatch() {
994
- while (this.queue.length && this.limit.tryAcquire()) {
995
- const bucket = this.queue[0][0];
996
- const waiting = this.queue.filter(([b]) => b === bucket).map(([, it]) => it);
997
- const [batch] = (0, engine_js_1.nextPack)(waiting, (i) => i.size, (i) => i.key, this.packTarget(), this.sizes.packMaxObjects);
998
- const chosen = new Set(batch);
999
- this.queue = this.queue.filter(([, it]) => !chosen.has(it));
1000
- this.limit.setPriority(this.queue.length > 0);
1001
- for (const it of batch) {
1177
+ for (;;) {
1178
+ const chosen = this.choose();
1179
+ if (!chosen)
1180
+ return;
1181
+ const [bucket, plan, reason] = chosen;
1182
+ for (const it of plan.pack) {
1002
1183
  it.picked = true;
1003
- it.wake?.resolve(); // its caller may be waiting for a slot: it has one now
1184
+ it.wake?.resolve(); // its caller may be waiting: its file has a pack now
1004
1185
  }
1005
- void this.runShared(new Pack(bucket, batch, this.memory));
1006
- }
1186
+ const p = new Pack(bucket, plan.pack, this.memory);
1187
+ p.underFloor = reason;
1188
+ p.inflight = plan.stored;
1189
+ this.inFlight.add(p.id, bucket, plan.stored);
1190
+ void this.runShared(p);
1191
+ }
1192
+ }
1193
+ /** The next pack to send and its reason (a slot taken for it), or undefined; sets the next decision and priority. */
1194
+ choose() {
1195
+ const now = this.clock();
1196
+ const speed = this.speed.bytesPerSecond;
1197
+ const requestBytes = this.speed.requestBytes();
1198
+ const [limits, slow] = (0, packing_js_1.atSpeed)(this.limits, requestBytes);
1199
+ const [choices, next] = (0, packing_js_1.schedule)(this.waiting, now, limits, (a, tailCap) => (0, packing_js_1.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 : (0, packing_js_1.absorbCap)(this.limits, requestBytes));
1200
+ this.nextDecision = next;
1201
+ let blocked = false;
1202
+ for (const c of choices) {
1203
+ const p = c.plan;
1204
+ if (this.inflight > 0 && this.inflight + p.stored > packing_js_1.MEMORY_CAP) {
1205
+ blocked = true;
1206
+ continue;
1207
+ }
1208
+ if (!this.limit.tryAcquire()) {
1209
+ blocked = true;
1210
+ continue;
1211
+ }
1212
+ const a = this.waiting.get(c.bucket);
1213
+ a.remove(p.pack);
1214
+ if (!a.size)
1215
+ this.waiting.delete(c.bucket);
1216
+ this.inflight += p.stored;
1217
+ this.limit.setPriority(blocked);
1218
+ return [c.bucket, p, this.reasonFor(p, c.decision.reason, slow)];
1219
+ }
1220
+ this.limit.setPriority(blocked);
1221
+ return undefined;
1222
+ }
1223
+ /** A pack's reason for being under the floor (section 28.9), or undefined at or over it. */
1224
+ reasonFor(p, reason, slow) {
1225
+ if (p.stored >= this.limits.floor)
1226
+ return undefined;
1227
+ if (slow)
1228
+ return 'slow_link';
1229
+ if (p.state === 'request_limited')
1230
+ return 'request_limit';
1231
+ return reason ?? 'leftover';
1007
1232
  }
1008
1233
  async runShared(p) {
1009
1234
  let outcome = 'failed';
@@ -1017,8 +1242,13 @@ class DirectUploads {
1017
1242
  it.error = err; // every caller gets its answer
1018
1243
  }
1019
1244
  finally {
1245
+ // Out of the packs in flight, into its grace time (28.25), before its slot is released. Its callers were woken as
1246
+ // each item was delivered: one that saved again meanwhile counted twice until here, which can only hold a pack, and
1247
+ // the release below has it decided again.
1248
+ this.inFlight.remove(p.id, this.clock(), p.state === 'committed');
1020
1249
  for (const it of p.items)
1021
1250
  it.done?.resolve();
1251
+ this.inflight -= p.inflight;
1022
1252
  this.release(outcome);
1023
1253
  }
1024
1254
  }
@@ -1239,6 +1469,7 @@ class DirectUploads {
1239
1469
  }
1240
1470
  async sendRun(state, onProgress, partSize, signal) {
1241
1471
  const { bucket, store, place, items } = state;
1472
+ items.forEach((it, i) => (it.order = i));
1242
1473
  const lim = this.packObjectLimit();
1243
1474
  // A file whose upload in parts a crash left open goes on in parts, whatever the speed now (section 22.7).
1244
1475
  const inParts = new Set(store.journal
@@ -1247,7 +1478,7 @@ class DirectUploads {
1247
1478
  .map((r) => str(r.file_id)));
1248
1479
  const large = items.filter((it) => it.size > lim || (it.size > 0 && it.fileId !== undefined && inParts.has(it.fileId)));
1249
1480
  const chosen = new Set(large);
1250
- const small = items.filter((it) => it.size > 0 && !chosen.has(it));
1481
+ let small = items.filter((it) => it.size > 0 && !chosen.has(it));
1251
1482
  // The small files are packed as slots come free, each pack at the speed measured by then (section 22.7).
1252
1483
  const packs = [...state.packs];
1253
1484
  let total = 0;
@@ -1299,32 +1530,56 @@ class DirectUploads {
1299
1530
  await this.abandon(store, place); // the last task of an interrupted run releases it
1300
1531
  }
1301
1532
  };
1302
- const tasks = [];
1533
+ // Files a pack gives back (a changed file's pack, a renewal under the floor) are planned again with the rest, their
1534
+ // place in the run kept (section 28.5).
1535
+ let back = [];
1536
+ let tasks = [];
1303
1537
  try {
1304
1538
  const start = (p) => {
1539
+ p.giveback = (returned) => back.push(...returned);
1305
1540
  closer.started();
1306
1541
  tasks.push(go(p).catch(failed));
1307
1542
  };
1308
- for (const p of [...packs]) {
1309
- await this.limit.acquire(ctl.signal);
1310
- if (failure) {
1311
- this.release('failed');
1312
- throw failure.err;
1543
+ // A first round dissolves the resumed packs under the floor (section 28.22, point 5), so their files are planned
1544
+ // with the queued ones; the other resumed packs and the queued files wait for it.
1545
+ const dissolving = packs.filter((p) => p.dissolve);
1546
+ for (const group of [dissolving, packs.filter((p) => !p.dissolve)]) {
1547
+ for (const p of group) {
1548
+ await this.limit.acquire(ctl.signal);
1549
+ if (failure) {
1550
+ this.release('failed');
1551
+ throw failure.err;
1552
+ }
1553
+ start(p);
1313
1554
  }
1314
- start(p);
1315
- }
1316
- for (let rest = small; rest.length;) {
1317
- await this.limit.acquire(ctl.signal);
1318
- if (failure) {
1319
- this.release('failed');
1320
- throw failure.err;
1555
+ if (group === dissolving && dissolving.length) {
1556
+ await (0, common_js_1.abortable)(Promise.all(tasks), ctl.signal);
1557
+ if (failure)
1558
+ throw failure.err;
1559
+ tasks = [];
1560
+ small = [...back, ...small].sort((a, b) => a.order - b.order);
1561
+ back = [];
1321
1562
  }
1322
- let chunk;
1323
- [chunk, rest] = (0, engine_js_1.nextPack)(rest, (i) => i.size, (i) => i.key, this.packTarget(), this.sizes.packMaxObjects);
1324
- const p = new Pack(bucket, chunk, store);
1325
- packs.push(p);
1326
- start(p);
1327
1563
  }
1564
+ // A pack for each slot that comes free, planned at the speed measured by then, the run's tail joining its last pack.
1565
+ const planSmall = async (files) => {
1566
+ for (let rest = files; rest.length;) {
1567
+ await this.limit.acquire(ctl.signal);
1568
+ if (failure) {
1569
+ this.release('failed');
1570
+ throw failure.err;
1571
+ }
1572
+ const [limits, slow] = (0, packing_js_1.atSpeed)(this.limits, this.speed.requestBytes());
1573
+ const plan = (0, packing_js_1.planPack)(rest, itemStored, itemKey, itemRequest, limits, true);
1574
+ rest = plan.rest;
1575
+ const p = new Pack(bucket, plan.pack, store);
1576
+ p.underFloor = this.reasonFor(plan, 'leftover', slow);
1577
+ p.rebuilds = Math.max(0, ...plan.pack.map((it) => it.rebuilds));
1578
+ packs.push(p);
1579
+ start(p);
1580
+ }
1581
+ };
1582
+ await planSmall(small);
1328
1583
  for (const it of large) {
1329
1584
  if (stop.length) {
1330
1585
  await this.done(store, [[it, undefined, stop[0], false]]);
@@ -1350,10 +1605,19 @@ class DirectUploads {
1350
1605
  throw err;
1351
1606
  }
1352
1607
  }
1353
- // A task that fails stops the run at once (its controller aborts), as joining it first would.
1354
- await (0, common_js_1.abortable)(Promise.all(tasks), ctl.signal);
1355
- if (failure)
1356
- throw failure.err;
1608
+ // A task that fails stops the run at once (its controller aborts), as joining it first would. What packs gave back
1609
+ // is planned again with its place in the run kept, until none gives any back.
1610
+ for (;;) {
1611
+ await (0, common_js_1.abortable)(Promise.all(tasks), ctl.signal);
1612
+ if (failure)
1613
+ throw failure.err;
1614
+ const again = back.sort((a, b) => a.order - b.order);
1615
+ back = [];
1616
+ if (!again.length)
1617
+ break;
1618
+ tasks = [];
1619
+ await planSmall(again);
1620
+ }
1357
1621
  }
1358
1622
  catch (err) {
1359
1623
  // Interrupted, or failed: every pack still under way is stopped and waited for (up to the cap); then the run's
@@ -1538,7 +1802,7 @@ class DirectUploads {
1538
1802
  const packs = [];
1539
1803
  for (const r of j.rows('packs')) {
1540
1804
  const pid = str(r._id);
1541
- if (r.state === 'committed' || r.state === 'failed')
1805
+ if (r.state === 'committed' || r.state === 'failed' || r.state === 'returned')
1542
1806
  continue;
1543
1807
  const its = byPack.get(pid);
1544
1808
  byPack.delete(pid);
@@ -1557,10 +1821,18 @@ class DirectUploads {
1557
1821
  p.attempts = int(r.attempts);
1558
1822
  if (p.state === 'uploading')
1559
1823
  p.state = 'unknown'; // the PUT may have landed: commit first
1824
+ // Not yet signed and under the floor (section 28.22, point 5): its files are planned again, once its idempotency
1825
+ // key has shown it wasn't committed (dissolvePack).
1826
+ p.dissolve = UNSIGNED.has(p.state) && its.reduce((n, it) => n + it.stored, 0) < this.limits.floor;
1560
1827
  packs.push(p);
1561
1828
  }
1562
1829
  for (const its of byPack.values())
1563
1830
  queued.push(...its); // packed into a pack that's gone: queue them again
1831
+ // Dissolving gains nothing when there is nothing to merge with: a lone small pack would be planned again as the same
1832
+ // leftover, and carrying on under its own idempotency key keeps a restored older journal from sending files twice.
1833
+ if (packs.filter((p) => p.dissolve).length + (queued.length ? 1 : 0) < 2)
1834
+ for (const p of packs)
1835
+ p.dissolve = false;
1564
1836
  await this.sweepOrphans(place, j, packs);
1565
1837
  return { state: { bucket, store, place, items: queued, packs, results }, changed };
1566
1838
  }