@teepin/sdk 0.3.0-rc.1 → 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.
@@ -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, COMMIT_VERIFY_TRIES, MAX_REBUILDS, PACK_MAX_OBJECTS, PACK_MIN_TARGET, PACK_OBJECT_LIMIT, PACK_TARGET_BYTES, PART_RULES, RetryBudget, SIGN_MARGIN_MS, START_CONCURRENCY, STREAM_CONCURRENCY, SpeedMeter, directPartSize, envNumber, isPassing, isThrottle, nextPack, packObjectLimit, packTarget, storageOutcome, streamPartSize, waitDelayMs, } from './engine.js';
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
- queue = [];
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
- while (p.state !== 'committed' && p.state !== 'failed') {
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
- switch (storageOutcome(err)) {
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, 70000));
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
- p.verifies++;
815
- if (unknown || p.verifies > COMMIT_VERIFY_TRIES) {
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
- await this.client.sleep(1000, signal);
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
- /** A new reservation and fresh keys for the pack's items (section 17.3). */
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.queue.push([bucket, it]);
901
- this.limit.setPriority(true);
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
- const deadline = Date.now() + wait;
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 queued = this.queue.some(([, i]) => i === it);
906
- if (queued) {
907
- this.queue = this.queue.filter(([, i]) => i !== it);
908
- this.limit.setPriority(this.queue.length > 0);
909
- }
910
- return queued;
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
- const slice = Math.min(left, MAX_TIMER_MS);
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
- /** Forms a pack from what's waiting whenever a send slot is free. Replaced in tests. */
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
- while (this.queue.length && this.limit.tryAcquire()) {
951
- const bucket = this.queue[0][0];
952
- const waiting = this.queue.filter(([b]) => b === bucket).map(([, it]) => it);
953
- const [batch] = nextPack(waiting, (i) => i.size, (i) => i.key, this.packTarget(), this.sizes.packMaxObjects);
954
- const chosen = new Set(batch);
955
- this.queue = this.queue.filter(([, it]) => !chosen.has(it));
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 for a slot: it has one now
1140
+ it.wake?.resolve(); // its caller may be waiting: its file has a pack now
960
1141
  }
961
- void this.runShared(new Pack(bucket, batch, this.memory));
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
- const small = items.filter((it) => it.size > 0 && !chosen.has(it));
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
- const tasks = [];
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
- for (const p of [...packs]) {
1265
- await this.limit.acquire(ctl.signal);
1266
- if (failure) {
1267
- this.release('failed');
1268
- throw failure.err;
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
- start(p);
1271
- }
1272
- for (let rest = small; rest.length;) {
1273
- await this.limit.acquire(ctl.signal);
1274
- if (failure) {
1275
- this.release('failed');
1276
- throw failure.err;
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
- await abortable(Promise.all(tasks), ctl.signal);
1311
- if (failure)
1312
- throw failure.err;
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
  }