swarm-deploy 0.3.1 → 0.4.2

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/server.js CHANGED
@@ -46,15 +46,21 @@ exports.MAX_ACTIVE_UPLOADS = MAX_ACTIVE_UPLOADS;
46
46
  const FINGERPRINT_LENGTH = 12;
47
47
  exports.FINGERPRINT_LENGTH = FINGERPRINT_LENGTH;
48
48
  const TAR_DURABILITY_BATCH_BYTES = 1024 * 1024;
49
+ /** How long a connection may linger so its peer can read a `HOOK_FAILED` answer before it is destroyed. */
50
+ const ANSWER_CLOSE_GRACE = 5_000;
49
51
  /**
50
52
  * How many transfers may owe a deferred post-commit retention pass at once.
51
53
  *
52
- * The set grows only when an `afterCommit` hook fails after a durable commit,
54
+ * The set grows only when an `afterCommit` or `afterLink` hook fails after a durable commit,
53
55
  * and entries are removed as soon as a retry succeeds, so reaching the bound
54
56
  * means a persistently failing hook. Capping it at the connection ceiling
55
57
  * keeps a broken deployment step from growing server memory without limit.
56
58
  */
57
59
  exports.MAX_OWED_POST_COMMIT_RETENTION = MAX_CONNECTIONS;
60
+ function releaseLinkHooks(pending) {
61
+ for (const item of pending)
62
+ item.turn.release();
63
+ }
58
64
  function fail(code, message, cause = null) {
59
65
  return new errors_js_1.SwarmDeployError(code, message, cause);
60
66
  }
@@ -64,6 +70,12 @@ function codeOf(error) {
64
70
  ? error.code
65
71
  : errors_js_1.ERRORS.PROTOCOL_INVALID;
66
72
  }
73
+ function frozenRelease(release) {
74
+ return Object.freeze({
75
+ series: release.series,
76
+ ...(release.version === undefined ? {} : { version: release.version })
77
+ });
78
+ }
67
79
  function positive(value, name, max = Number.MAX_SAFE_INTEGER) {
68
80
  if (!Number.isSafeInteger(value) || value <= 0 || value > max) {
69
81
  throw fail(errors_js_1.ERRORS.PROTOCOL_INVALID, `Invalid ${name}`);
@@ -136,7 +148,7 @@ class Server extends EventEmitter {
136
148
  activeUploads = new Set();
137
149
  /**
138
150
  * Transfer IDs this process committed but whose deferred post-commit
139
- * retention is still owed because `afterCommit` has not yet succeeded. Only
151
+ * retention is still owed because `afterCommit` or `afterLink` has not yet succeeded. Only
140
152
  * these may start a retention pass from an already-committed retry, so an
141
153
  * ordinary duplicate offer cannot be used to force repeated full passes.
142
154
  * The set is in-memory only and bounded by `MAX_OWED_POST_COMMIT_RETENTION`;
@@ -152,6 +164,13 @@ class Server extends EventEmitter {
152
164
  * and its hooks concurrently against one staging and commit identity.
153
165
  */
154
166
  activeTransfers = new Set();
167
+ /**
168
+ * One first-in, first-out line of `afterLink` calls per link name. A place is
169
+ * reserved while the root lease orders the link decision, and the hook runs
170
+ * after the lease is released, so a link's hooks start in decision order
171
+ * and never overlap, while hook code never runs under the lease.
172
+ */
173
+ linkHookTurns = new hooks_js_1.HookTurns();
155
174
  receives = new Set();
156
175
  layout = null;
157
176
  sessions = null;
@@ -305,25 +324,40 @@ class Server extends EventEmitter {
305
324
  transferId: metadata.transferId,
306
325
  ...((0, controls_js_1.isTreeMetadata)(metadata) ? { entryCount: metadata.entryCount } : {}),
307
326
  ...(metadata.sourceParent === undefined ? {} : { sourceParent: metadata.sourceParent }),
308
- ...(release === null
309
- ? {}
310
- : {
311
- release: Object.freeze({
312
- series: release.series,
313
- ...(release.version === undefined ? {} : { version: release.version })
314
- })
315
- })
327
+ ...(release === null ? {} : { release: frozenRelease(release) })
328
+ });
329
+ }
330
+ /** Describes a durably committed artifact from its commit record, as a link hook sees it. */
331
+ recordArtifact(record) {
332
+ return Object.freeze({
333
+ name: record.name,
334
+ kind: (0, commit_journal_js_1.commitRecordKind)(record),
335
+ size: record.size,
336
+ sha256: record.sha256,
337
+ transferId: record.transferId,
338
+ ...(record.entryCount === undefined ? {} : { entryCount: record.entryCount }),
339
+ ...(record.release === undefined ? {} : { release: frozenRelease(record.release) })
340
+ });
341
+ }
342
+ hookLink(link, mode) {
343
+ return Object.freeze({
344
+ name: link.name,
345
+ target: link.target,
346
+ mode,
347
+ path: _path_1.default.join(this.layout.root, link.name),
348
+ targetPath: _path_1.default.join(this.layout.root, link.target)
316
349
  });
317
350
  }
318
351
  /**
319
352
  * Computes and converges the desired links from durable commit records.
320
353
  * Called with the root lease already held.
321
354
  */
322
- async reconcileLinksUnlocked(records) {
355
+ async convergeLinksUnlocked(records) {
323
356
  const desired = (0, symlinks_js_1.selectDesiredLinks)(this.symlinks, records);
324
- if (!this.links)
325
- return new Set(desired.map((link) => link.transferId));
326
- await this.links.reconcile(desired, this.linkNames, {
357
+ if (!this.links) {
358
+ return { pins: new Set(desired.map((link) => link.transferId)), desired, result: null };
359
+ }
360
+ const result = await this.links.reconcile(desired, this.linkNames, {
327
361
  managedArtifactNames: new Set(records.map((record) => record.name))
328
362
  });
329
363
  const ledger = await this.links.list();
@@ -338,7 +372,10 @@ class Server extends EventEmitter {
338
372
  else
339
373
  pins.add(link.transferId);
340
374
  }
341
- return pins;
375
+ return { pins, desired, result };
376
+ }
377
+ async reconcileLinksUnlocked(records) {
378
+ return (await this.convergeLinksUnlocked(records)).pins;
342
379
  }
343
380
  /** Pins link targets when reconciliation fails but retention must continue. */
344
381
  async linkPinsFallbackUnlocked(records) {
@@ -353,12 +390,37 @@ class Server extends EventEmitter {
353
390
  }
354
391
  return pins;
355
392
  }
356
- /** Reconciles after a durable commit; this is the only caller that leases the root. */
357
- async reconcileAfterCommit() {
393
+ /**
394
+ * Reconciles after a durable commit; this is the only caller that leases the
395
+ * root for it. When an `afterLink` hook is configured it also returns one
396
+ * pending call for every automatic link that now designates `artifact`,
397
+ * whether this reconcile changed it or an earlier attempt already had, so a
398
+ * retry re-runs a hook that failed. The caller owns the returned places in
399
+ * the hook lines and must release them.
400
+ */
401
+ async reconcileAfterCommit(artifact) {
358
402
  if (!this.links || !this.commits)
359
- return;
403
+ return [];
360
404
  try {
361
- await (0, root_coordinator_js_1.withRootLease)(this.layout.root, async () => this.reconcileLinksUnlocked(await this.commits.list()));
405
+ return await (0, root_coordinator_js_1.withRootLease)(this.layout.root, async () => {
406
+ const { desired, result } = await this.convergeLinksUnlocked(await this.commits.list());
407
+ if (this.hooks.afterLink === undefined)
408
+ return [];
409
+ const unchanged = new Set(result?.unchanged);
410
+ const pending = desired
411
+ .filter((link) => link.transferId === artifact.transferId)
412
+ .map((link) => ({
413
+ link: this.hookLink(link, 'automatic'),
414
+ artifact,
415
+ status: unchanged.has(link.name) ? 'UNCHANGED' : 'LINKED',
416
+ trigger: 'upload'
417
+ }));
418
+ // Reserved last and without a failure point after it, so no place can leak.
419
+ return pending.map((item) => ({
420
+ ...item,
421
+ turn: this.linkHookTurns.reserve(item.link.name)
422
+ }));
423
+ });
362
424
  }
363
425
  catch (error) {
364
426
  if (error instanceof errors_js_1.SwarmDeployError && error.code === errors_js_1.ERRORS.LINK_CONFLICT)
@@ -366,6 +428,41 @@ class Server extends EventEmitter {
366
428
  throw new errors_js_1.SwarmDeployError(errors_js_1.ERRORS.LINK_FAILED, 'Unable to reconcile managed symlinks', error);
367
429
  }
368
430
  }
431
+ /**
432
+ * Runs pending `afterLink` calls one after another, each once every earlier
433
+ * hook for its link has finished. No storage lease is held while hook code
434
+ * runs. `begin` is told which call is starting so a caller can attribute a
435
+ * failure. The first failure stops the rest; the caller releases the places.
436
+ */
437
+ async runLinkHooks(pending, owner, begin) {
438
+ for (const item of pending) {
439
+ begin(item);
440
+ if ((await item.turn.ready(this.signal)) === 'aborted')
441
+ throw (0, abort_js_1.abortError)();
442
+ try {
443
+ await this.runHook('afterLink', this.hooks.afterLink, Object.freeze({
444
+ link: item.link,
445
+ artifact: item.artifact,
446
+ status: item.status,
447
+ trigger: item.trigger
448
+ }));
449
+ }
450
+ catch (error) {
451
+ if (error instanceof errors_js_1.SwarmDeployError && error.code === errors_js_1.ERRORS.HOOK_FAILED) {
452
+ this.logger.warn('Link hook failed', {
453
+ fingerprint: owner ? fingerprint(owner) : 'invalid',
454
+ name: item.link.name,
455
+ target: item.link.target,
456
+ trigger: item.trigger,
457
+ status: item.status,
458
+ reason: errors_js_1.ERRORS.HOOK_FAILED
459
+ });
460
+ }
461
+ throw error;
462
+ }
463
+ item.turn.release();
464
+ }
465
+ }
369
466
  setManagedLinkNames(records) {
370
467
  this.managedLinkNamesSnapshot = new Set([
371
468
  ...this.linkNames,
@@ -382,7 +479,13 @@ class Server extends EventEmitter {
382
479
  async receiveLink(socket, reader, owner, request) {
383
480
  let emitted = false;
384
481
  let resultWriteStarted = false;
385
- const emitTerminal = (status, reason) => {
482
+ // `durable` is set once the link is durable, so a later hook failure can report what the
483
+ // link did; `failed` is the hook that was running. Held in an object because a closure sets it.
484
+ const state = {
485
+ durable: null,
486
+ failed: null
487
+ };
488
+ const emitTerminal = (status, reason, result) => {
386
489
  if (emitted)
387
490
  return;
388
491
  emitted = true;
@@ -391,7 +494,8 @@ class Server extends EventEmitter {
391
494
  target: request.target,
392
495
  name: request.name,
393
496
  status,
394
- ...(reason === undefined ? {} : { reason })
497
+ ...(reason === undefined ? {} : { reason }),
498
+ ...(result === undefined ? {} : { result })
395
499
  });
396
500
  };
397
501
  try {
@@ -406,7 +510,7 @@ class Server extends EventEmitter {
406
510
  if (this.linkNames.has(request.name)) {
407
511
  throw fail(errors_js_1.ERRORS.LINK_NOT_ALLOWED, 'Manual link name is reserved by an automatic rule');
408
512
  }
409
- const result = await (0, root_coordinator_js_1.withRootLease)(this.layout.root, async () => {
513
+ const decided = await (0, root_coordinator_js_1.withRootLease)(this.layout.root, async () => {
410
514
  const records = await this.commits.list();
411
515
  const target = records.find((record) => record.name === request.target && !(0, files_js_1.isReservedHistoryName)(record.name));
412
516
  if (!target)
@@ -428,30 +532,58 @@ class Server extends EventEmitter {
428
532
  automaticLinkNames: this.linkNames
429
533
  });
430
534
  await this.refreshManagedLinkNames();
431
- return linked;
535
+ // The link is durable here. Its hook runs after this lease is released, so a
536
+ // slow or failing hook never blocks commits, retention, or other links.
537
+ const pending = this.hooks.afterLink === undefined
538
+ ? null
539
+ : {
540
+ link: this.hookLink(link, 'manual'),
541
+ artifact: this.recordArtifact(target),
542
+ status: linked.status,
543
+ trigger: 'request',
544
+ turn: this.linkHookTurns.reserve(link.name)
545
+ };
546
+ return { status: linked.status, pending };
432
547
  });
548
+ state.durable = decided.status;
549
+ if (decided.pending !== null) {
550
+ const pending = decided.pending;
551
+ try {
552
+ await this.runLinkHooks([pending], owner, (item) => {
553
+ state.failed = item;
554
+ });
555
+ }
556
+ finally {
557
+ pending.turn.release();
558
+ }
559
+ }
433
560
  resultWriteStarted = true;
434
- await (0, direct_wire_js_1.writeLinkResult)(socket, { v: 1, status: result.status }, { signal: this.signal, timeout: this.idleTimeout });
435
- emitTerminal(result.status === 'LINKED' ? 'linked' : 'unchanged');
561
+ await (0, direct_wire_js_1.writeLinkResult)(socket, { v: 1, status: decided.status }, { signal: this.signal, timeout: this.idleTimeout });
562
+ emitTerminal(decided.status === 'LINKED' ? 'linked' : 'unchanged');
436
563
  }
437
564
  catch (error) {
438
565
  let reason = error instanceof errors_js_1.SwarmDeployError &&
439
566
  (error.code === errors_js_1.ERRORS.LINK_NOT_ALLOWED ||
440
567
  error.code === errors_js_1.ERRORS.LINK_TARGET_NOT_FOUND ||
441
568
  error.code === errors_js_1.ERRORS.LINK_CONFLICT ||
442
- error.code === errors_js_1.ERRORS.LINK_FAILED)
569
+ error.code === errors_js_1.ERRORS.LINK_FAILED ||
570
+ error.code === errors_js_1.ERRORS.HOOK_FAILED)
443
571
  ? error.code
444
572
  : errors_js_1.ERRORS.LINK_FAILED;
445
573
  const rejected = reason === errors_js_1.ERRORS.LINK_NOT_ALLOWED ||
446
574
  reason === errors_js_1.ERRORS.LINK_TARGET_NOT_FOUND ||
447
575
  reason === errors_js_1.ERRORS.LINK_CONFLICT;
576
+ let answered = false;
448
577
  if (!resultWriteStarted) {
449
578
  resultWriteStarted = true;
450
579
  try {
451
580
  await (0, direct_wire_js_1.writeLinkResult)(socket, { v: 1, status: 'FAILED', code: reason }, { timeout: this.idleTimeout });
581
+ answered = true;
452
582
  }
453
583
  catch {
454
- reason = errors_js_1.ERRORS.LINK_FAILED;
584
+ // A failed hook stays observable here even when the client cannot be told.
585
+ if (reason !== errors_js_1.ERRORS.HOOK_FAILED)
586
+ reason = errors_js_1.ERRORS.LINK_FAILED;
455
587
  }
456
588
  }
457
589
  else {
@@ -459,11 +591,61 @@ class Server extends EventEmitter {
459
591
  }
460
592
  this.logger.warn('Direct link failed', { fingerprint: fingerprint(owner), reason });
461
593
  this.emitSafe('failure', { fingerprint: fingerprint(owner), reason });
462
- emitTerminal(rejected && reason !== errors_js_1.ERRORS.LINK_FAILED ? 'rejected' : 'failed', reason);
594
+ emitTerminal(rejected && reason !== errors_js_1.ERRORS.LINK_FAILED ? 'rejected' : 'failed', reason, reason === errors_js_1.ERRORS.HOOK_FAILED && state.durable !== null ? state.durable : undefined);
595
+ if (answered && reason === errors_js_1.ERRORS.HOOK_FAILED) {
596
+ this.closeAfterAnswer(socket);
597
+ }
598
+ else {
599
+ try {
600
+ socket.destroy();
601
+ }
602
+ catch { }
603
+ }
604
+ // The client has been answered and the link is already durable; onFailure only observes.
605
+ if (reason === errors_js_1.ERRORS.HOOK_FAILED && state.failed !== null) {
606
+ await this.runFailureHook(Object.freeze({
607
+ artifact: state.failed.artifact,
608
+ path: state.failed.link.path,
609
+ phase: 'afterLink',
610
+ resumed: false,
611
+ alreadyCommitted: true,
612
+ error: (0, hooks_js_1.callbackError)(error),
613
+ link: state.failed.link
614
+ }), owner, fingerprint(b4a_1.default.from(state.failed.artifact.transferId, 'hex')));
615
+ }
616
+ }
617
+ }
618
+ /**
619
+ * Closes a connection after its `HOOK_FAILED` answer was written. Destroying
620
+ * the stream right behind the write can reset it before the peer has read the
621
+ * record, so the caller would see a reset instead of the stable code. The
622
+ * write side is ended first and the connection is destroyed once the peer
623
+ * closes it, or after a grace period if it never does. The work that failed
624
+ * is already durable, so lingering costs only the connection.
625
+ */
626
+ closeAfterAnswer(socket) {
627
+ let timer;
628
+ const destroy = () => {
629
+ clearTimeout(timer);
463
630
  try {
464
631
  socket.destroy();
465
632
  }
466
633
  catch { }
634
+ };
635
+ try {
636
+ ;
637
+ socket.end?.();
638
+ }
639
+ catch {
640
+ destroy();
641
+ return;
642
+ }
643
+ socket.once('close', () => clearTimeout(timer));
644
+ timer = setTimeout(destroy, ANSWER_CLOSE_GRACE);
645
+ // A lingering answer must never keep the process alive on its own.
646
+ if (typeof timer === 'object' && timer !== null && 'unref' in timer) {
647
+ if (typeof timer.unref === 'function')
648
+ timer.unref();
467
649
  }
468
650
  }
469
651
  /** Runs a gating callback; only callback exceptions become HOOK_FAILED. */
@@ -540,6 +722,7 @@ class Server extends EventEmitter {
540
722
  let artifact = null;
541
723
  let phase = 'offer';
542
724
  let hookPath = null;
725
+ let failedLink = null;
543
726
  let resumed = false;
544
727
  let alreadyCommitted = false;
545
728
  let hooksFinished = false;
@@ -566,7 +749,21 @@ class Server extends EventEmitter {
566
749
  // finished its own. `onFailure` only observes, so holding the transfer
567
750
  // across a slow callback would reject the client's legitimate retry.
568
751
  releaseTransfer();
569
- await this.runFailureHook(Object.freeze({ artifact, path: hookPath, phase, resumed, alreadyCommitted, error }), owner, event.transfer);
752
+ await this.runFailureHook(Object.freeze({
753
+ artifact,
754
+ path: hookPath,
755
+ phase,
756
+ resumed,
757
+ alreadyCommitted,
758
+ error,
759
+ ...(phase === 'afterLink' && failedLink !== null ? { link: failedLink } : {})
760
+ }), owner, event.transfer);
761
+ };
762
+ // Attributes an `afterLink` failure to the link whose hook was starting.
763
+ const beginLinkHook = (item) => {
764
+ phase = 'afterLink';
765
+ failedLink = item.link;
766
+ hookPath = item.link.path;
570
767
  };
571
768
  const rejectEarly = async (reason, message) => {
572
769
  await this.rejectOffer(socket, event, owner, reason);
@@ -629,7 +826,7 @@ class Server extends EventEmitter {
629
826
  signal: this.signal,
630
827
  replaceNames: this.replaceNames,
631
828
  release,
632
- deferPostCommitRetention: this.hooks.afterCommit !== undefined
829
+ deferPostCommitRetention: this.defersPostCommitRetention()
633
830
  });
634
831
  this.emitSafe('commit', {
635
832
  ...event,
@@ -639,17 +836,23 @@ class Server extends EventEmitter {
639
836
  commitSucceeded = true;
640
837
  hookPath = _path_1.default.join(this.layout.root, metadata.name);
641
838
  await this.retireQuietly(verified.transferId, owner);
642
- await this.reconcileAfterCommit();
643
- phase = 'afterCommit';
644
- if (this.hooks.afterCommit !== undefined) {
645
- this.owePostCommitRetention(metadata.transferId);
839
+ const linkHooks = await this.reconcileAfterCommit(hookArtifact);
840
+ try {
841
+ phase = 'afterCommit';
842
+ if (this.defersPostCommitRetention()) {
843
+ this.owePostCommitRetention(metadata.transferId);
844
+ }
845
+ await this.runHook('afterCommit', this.hooks.afterCommit, Object.freeze({
846
+ artifact: hookArtifact,
847
+ path: hookPath,
848
+ resumed,
849
+ alreadyCommitted: false
850
+ }));
851
+ await this.runLinkHooks(linkHooks, owner, beginLinkHook);
852
+ }
853
+ finally {
854
+ releaseLinkHooks(linkHooks);
646
855
  }
647
- await this.runHook('afterCommit', this.hooks.afterCommit, Object.freeze({
648
- artifact: hookArtifact,
649
- path: hookPath,
650
- resumed,
651
- alreadyCommitted: false
652
- }));
653
856
  hooksFinished = true;
654
857
  await this.settleDeferredRetention(metadata.transferId, owner);
655
858
  finalStarted = true;
@@ -677,14 +880,20 @@ class Server extends EventEmitter {
677
880
  if (inspected.status === 'ALREADY_COMMITTED') {
678
881
  alreadyCommitted = true;
679
882
  hookPath = _path_1.default.join(this.layout.root, metadata.name);
680
- await this.reconcileAfterCommit();
681
- phase = 'afterCommit';
682
- await this.runHook('afterCommit', this.hooks.afterCommit, Object.freeze({
683
- artifact: hookArtifact,
684
- path: hookPath,
685
- resumed: false,
686
- alreadyCommitted: true
687
- }));
883
+ const linkHooks = await this.reconcileAfterCommit(hookArtifact);
884
+ try {
885
+ phase = 'afterCommit';
886
+ await this.runHook('afterCommit', this.hooks.afterCommit, Object.freeze({
887
+ artifact: hookArtifact,
888
+ path: hookPath,
889
+ resumed: false,
890
+ alreadyCommitted: true
891
+ }));
892
+ await this.runLinkHooks(linkHooks, owner, beginLinkHook);
893
+ }
894
+ finally {
895
+ releaseLinkHooks(linkHooks);
896
+ }
688
897
  hooksFinished = true;
689
898
  // Only a transfer this process committed can still owe a deferred
690
899
  // pass; an ordinary duplicate offer never starts one.
@@ -821,19 +1030,27 @@ class Server extends EventEmitter {
821
1030
  reason
822
1031
  });
823
1032
  }
1033
+ let answered = false;
824
1034
  try {
825
1035
  if (!sentAdmission) {
826
1036
  await (0, direct_wire_js_1.writeAdmission)(socket, { v: 1, status: 'REJECTED', code: reason }, { timeout: this.idleTimeout });
1037
+ answered = true;
827
1038
  }
828
1039
  else if (!finalStarted) {
829
1040
  await (0, direct_wire_js_1.writeFinal)(socket, { v: 1, status: 'FAILED', code: reason }, { timeout: this.idleTimeout });
1041
+ answered = true;
830
1042
  }
831
1043
  }
832
1044
  catch { }
833
- try {
834
- socket.destroy();
1045
+ if (answered && reason === errors_js_1.ERRORS.HOOK_FAILED) {
1046
+ this.closeAfterAnswer(socket);
1047
+ }
1048
+ else {
1049
+ try {
1050
+ socket.destroy();
1051
+ }
1052
+ catch { }
835
1053
  }
836
- catch { }
837
1054
  this.activeUploads.delete(socket);
838
1055
  if (!hooksFinished)
839
1056
  await reportFailure((0, hooks_js_1.callbackError)(error));
@@ -864,14 +1081,26 @@ class Server extends EventEmitter {
864
1081
  this.pendingAfterCommit.add(transferId);
865
1082
  }
866
1083
  /**
867
- * Runs the post-commit retention pass that a configured `afterCommit` hook
868
- * deferred, only after that hook succeeded, and then stops owing it. The
869
- * retention manager already reports its own failures as non-fatal, so
870
- * nothing here can fail the upload; the pass is attempted exactly once per
871
- * successful hook, which is why the transfer is cleared either way.
1084
+ * Whether the post-commit retention pass waits for the hooks that follow a
1085
+ * commit. Either one defers it: `afterCommit` so its callback still finds
1086
+ * the artifact, and `afterLink` so this commit's own link reconciliation runs
1087
+ * before this commit's retention pass does. That narrows, but does not remove,
1088
+ * the chance that the link already moved: another commit's retention or
1089
+ * reconciliation can do it first, and the hook then sees `UNCHANGED`.
1090
+ */
1091
+ defersPostCommitRetention() {
1092
+ return this.hooks.afterCommit !== undefined || this.hooks.afterLink !== undefined;
1093
+ }
1094
+ /**
1095
+ * Runs the post-commit retention pass that configured `afterCommit` or
1096
+ * `afterLink` hooks deferred, only after they succeeded, and then stops
1097
+ * owing it. The retention manager already reports its own failures as
1098
+ * non-fatal, so nothing here can fail the upload; the pass is attempted
1099
+ * exactly once per successful hook run, which is why the transfer is cleared
1100
+ * either way.
872
1101
  */
873
1102
  async settleDeferredRetention(transferId, owner) {
874
- if (this.hooks.afterCommit === undefined)
1103
+ if (!this.defersPostCommitRetention())
875
1104
  return;
876
1105
  try {
877
1106
  if (this.retention)
@@ -910,6 +1139,7 @@ class Server extends EventEmitter {
910
1139
  try {
911
1140
  this.layout = (0, layout_js_1.initLayout)(this.storageDir);
912
1141
  this.releaseLock = await (0, layout_js_1.acquireStorageLock)(this.layout, { storage: this.storage });
1142
+ await (0, layout_js_1.assertNoPendingStorageReset)(this.layout, this.storage);
913
1143
  this.sessions = new session_store_js_1.SessionStore({
914
1144
  layout: this.layout,
915
1145
  maxStagingBytes: this.maxStagingBytes,