@adonis-agora/durable 0.56.5 → 0.56.6

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.
@@ -7,6 +7,7 @@ import { instantCheckpoint, lostStepEvent, stepCheckpoint } from './checkpoints.
7
7
  import { CHILD_DELIVERED_TAG, markChildDelivered } from './child-delivery.js';
8
8
  import { completionError, failedCompletion, isFailedCompletion, } from './completion.js';
9
9
  import { assertConcurrency, concurrencyTag, countRuns, resolveConcurrency, } from './concurrency.js';
10
+ import { DISPATCH_KEY, dispatchKey } from './dispatch-key.js';
10
11
  import { controlPlaneDescriptor, planDispatch, WorkflowBlocked, } from './dispatch-routing.js';
11
12
  import { parseDuration } from './duration.js';
12
13
  import { Entities } from './entities.js';
@@ -140,6 +141,19 @@ const BUFFERED_SWEEP_BUDGET_MS = 250;
140
141
  const BUFFERED_SWEEP_REST_MS = 10 * 60_000;
141
142
  /** The most results of one run settled by one batched write (see `completeRemoteResult`). */
142
143
  const RESULT_BATCH_MAX = 256;
144
+ /** The most runs one owed-resume write marks (see `WorkflowEngine.markResumeOwed`). */
145
+ const OWED_RESUME_BATCH_MAX = 500;
146
+ /**
147
+ * How long a settled step result's acknowledgement waits for the run's resume before it marks the
148
+ * run as owed one instead (see `WorkflowEngine.resumeOwed`). A turn that ends within it needs no mark.
149
+ */
150
+ const OWED_RESUME_GRACE_MS = 3_000;
151
+ /**
152
+ * The results `execute` returns when another instance holds the run's lease — no turn ran here, so the
153
+ * wake that asked for it is not served yet — each with the durable request that wake made instead
154
+ * (see `WorkflowEngine.resumeOwed`). By identity, so the result keeps the shape every caller sees.
155
+ */
156
+ const leaseRefused = new WeakMap();
143
157
  /**
144
158
  * How many runs this instance executes in-process at once when it runs them itself (the default
145
159
  * dispatcher without a run queue, deferred child starts): past it, run ids wait in memory in arrival
@@ -320,6 +334,10 @@ export class WorkflowEngine {
320
334
  pending = new Map();
321
335
  /** Each run's step results waiting to be settled on this instance — see {@link completeRemoteResult}. */
322
336
  resultQueues = new Map();
337
+ /** Runs waiting to be marked as owed a resume by the next batched write — see {@link markResumeOwed}. */
338
+ owedResumes = [];
339
+ /** Whether an owed-resume write is in flight (the runs marked meanwhile go out in the next). */
340
+ markingOwedResumes = false;
323
341
  /** Per-step "reset the liveness timer" callbacks, called with the beat's worker timestamp when a
324
342
  * heartbeat arrives. */
325
343
  heartbeatResets = new Map();
@@ -2196,8 +2214,13 @@ export class WorkflowEngine {
2196
2214
  }
2197
2215
  // A resume issued from INSIDE this run's own execution (its ambient ctx is this run) can only be
2198
2216
  // satisfied once that execution ends — awaiting it there would deadlock. Queue it, answer now.
2199
- if (workflowAls.getStore()?.runId === runId)
2200
- return this.echoCurrentStatus(runId);
2217
+ if (workflowAls.getStore()?.runId === runId) {
2218
+ // Queued, not run: the wake is not served yet (see `resumeOwed`).
2219
+ return this.echoCurrentStatus(runId).then((echoed) => {
2220
+ leaseRefused.set(echoed, Promise.resolve('failed'));
2221
+ return echoed;
2222
+ });
2223
+ }
2201
2224
  return queued.result;
2202
2225
  }
2203
2226
  /** Drop `gate` once `tail` settles, unless another execution queued behind it meanwhile. */
@@ -2742,10 +2765,12 @@ export class WorkflowEngine {
2742
2765
  * ends normally settles after its dispatches, so a step it sent is always older than its settle).
2743
2766
  * `sinceInclusive` is for a run still `running`: its `updatedAt` is that same turn's start.
2744
2767
  *
2745
- * Each step goes out again under its own `stepId` and attempt: a step whose original dispatch did
2746
- * land runs twice (at-least-once, as documented), and the second result finds the checkpoint
2747
- * settled and is dropped. Only on a crash — a healthy run never gets here. Remote (polyglot)
2748
- * workflows are left to their own turn re-drive. Returns how many steps were sent.
2768
+ * Each step goes out again under its own `stepId` and attempt, keyed like its original dispatch
2769
+ * (see `dispatch-key.ts`): a broker that deduplicates by key (BullMQ) adds nothing while the original
2770
+ * job still waits or runs, or has just finished. Elsewhere a step whose original dispatch did land
2771
+ * runs twice (at-least-once, as documented), and the second result finds the checkpoint settled and
2772
+ * is dropped. Only on a crash — a healthy run never gets here. Remote (polyglot) workflows are left to
2773
+ * their own turn re-drive. Returns how many steps were sent.
2749
2774
  */
2750
2775
  async redispatchCrashedTurnSteps(run, checkpoints, sinceInclusive = false) {
2751
2776
  const registered = this.workflows.get(versionKey(run.workflow, run.workflowVersion)) ??
@@ -2770,6 +2795,8 @@ export class WorkflowEngine {
2770
2795
  input: cp.input,
2771
2796
  attempt: cp.attempts,
2772
2797
  priority: run.priority,
2798
+ // The original dispatch's key: a broker that still holds that job adds nothing.
2799
+ enqueuedAt: cp.enqueuedAt,
2773
2800
  });
2774
2801
  this.emit({
2775
2802
  type: 'step.started',
@@ -3982,21 +4009,12 @@ export class WorkflowEngine {
3982
4009
  });
3983
4010
  }
3984
4011
  /**
3985
- * A wake that found the run leased by another instance (its turn is executing) asks for another
3986
- * turn, durably: it stamps {@link WorkflowRun.wakeRequestedAt} on the run. The holder does not let
3987
- * go of the lease while the stamp is there ({@link releaseSuspendedLease}) and runs another turn,
3988
- * which sees what the wake wrote; and a `suspended` run carrying it is due for the timer poll, so
3989
- * the request survives both this instance and the holder going down.
3990
- *
3991
- * The holder may have let go between the refused lease and the stamp: nobody but the timer poll
3992
- * would see it then, so the lease is tried once more here. The in-memory re-check
3993
- * ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
3994
- * stops as soon as a turn served the request.
4012
+ * The durable half of {@link requestWake}: keep the wake's reason, then stamp
4013
+ * {@link WorkflowRun.wakeRequestedAt} on the run. `stamped`; `gone` when the run is no longer live
4014
+ * (nothing to wake); `failed` when the stamp could not be kept (a schema without the column, a store
4015
+ * error). Never rejects.
3995
4016
  */
3996
- async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }) {
3997
- if (this.draining)
3998
- return;
3999
- let marked;
4017
+ async stampWakeRequest(runId, reason) {
4000
4018
  try {
4001
4019
  // The reason first, the stamp second: a holder that finds the stamp finds the reason too.
4002
4020
  await this.store
@@ -4007,16 +4025,37 @@ export class WorkflowEngine {
4007
4025
  publishedAt: this.clock(),
4008
4026
  })
4009
4027
  .catch(() => undefined);
4010
- marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
4028
+ const marked = await this.store.updateRunIf(runId, ['running', 'suspended', 'blocked'], {
4011
4029
  wakeRequestedAt: this.clock(),
4012
4030
  });
4031
+ return marked ? 'stamped' : 'gone';
4013
4032
  }
4014
4033
  catch {
4034
+ return 'failed';
4035
+ }
4036
+ }
4037
+ /**
4038
+ * A wake that found the run leased by another instance (its turn is executing) asks for another
4039
+ * turn, durably: it stamps {@link WorkflowRun.wakeRequestedAt} on the run. The holder does not let
4040
+ * go of the lease while the stamp is there ({@link releaseSuspendedLease}) and runs another turn,
4041
+ * which sees what the wake wrote; and a `suspended` run carrying it is due for the timer poll, so
4042
+ * the request survives both this instance and the holder going down.
4043
+ *
4044
+ * The holder may have let go between the refused lease and the stamp: nobody but the timer poll
4045
+ * would see it then, so the lease is tried once more here. The in-memory re-check
4046
+ * ({@link recheckAfterLease}) runs too, as the fast path when the holder goes down mid-turn; it
4047
+ * stops as soon as a turn served the request.
4048
+ */
4049
+ async requestWake(runId, lockedUntil, reason = { generic: true, fans: new Map() }, stamping) {
4050
+ if (!stamping && this.draining)
4051
+ return;
4052
+ const stamp = await (stamping ?? this.stampWakeRequest(runId, reason));
4053
+ if (stamp === 'failed') {
4015
4054
  // Not kept (a schema without the column, a store error): the in-memory re-check is all there is.
4016
4055
  this.recheckAfterLease(runId, lockedUntil);
4017
4056
  return;
4018
4057
  }
4019
- if (!marked)
4058
+ if (stamp === 'gone')
4020
4059
  return;
4021
4060
  // The fast path for a holder that went down mid-turn: look again in memory too (it stops once a
4022
4061
  // turn served the request).
@@ -4160,8 +4199,11 @@ export class WorkflowEngine {
4160
4199
  this.wakeReasons.delete(run.id);
4161
4200
  if (run.lockedBy !== this.instanceId || (run.lockedUntil ?? 0) <= lockNow) {
4162
4201
  if (!(await this.store.tryLockRun(run.id, this.instanceId, lockNow + this.leaseMs, lockNow))) {
4163
- this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason));
4164
- return { runId: run.id, status: run.status };
4202
+ const stamping = this.draining ? undefined : this.stampWakeRequest(run.id, reason);
4203
+ this.trackEffect(this.requestWake(run.id, run.lockedUntil, reason, stamping));
4204
+ const refused = { runId: run.id, status: run.status };
4205
+ leaseRefused.set(refused, stamping ?? Promise.resolve('failed'));
4206
+ return refused;
4165
4207
  }
4166
4208
  }
4167
4209
  // This turn reads the run's checkpoints after this point, so it sees whatever the wakes that
@@ -6490,6 +6532,7 @@ export class WorkflowEngine {
6490
6532
  priority: admission?.priority,
6491
6533
  attempt,
6492
6534
  transport,
6535
+ enqueuedAt,
6493
6536
  });
6494
6537
  this.emit({ type: 'step.started', runId, seq, name: step.name, kind: 'remote' });
6495
6538
  throw new WorkflowSuspended(undefined, [seq]);
@@ -6695,6 +6738,7 @@ export class WorkflowEngine {
6695
6738
  priority: opts.admission?.priority,
6696
6739
  lazyPickupBeat: true,
6697
6740
  attempt: 1,
6741
+ [DISPATCH_KEY]: dispatchKey(cp.stepId, 1, enqueuedAt),
6698
6742
  })), opts.transport);
6699
6743
  // Awaited below; a rejection is not left unhandled while the next round is written.
6700
6744
  dispatch.catch(() => { });
@@ -6719,7 +6763,7 @@ export class WorkflowEngine {
6719
6763
  * {@link redispatchPending}. Carries the current trace/context so a re-dispatch is traceable.
6720
6764
  */
6721
6765
  dispatchRemoteTask(task) {
6722
- return this.pool.dispatch({
6766
+ const remote = {
6723
6767
  runId: task.runId,
6724
6768
  seq: task.seq,
6725
6769
  name: task.name,
@@ -6730,7 +6774,10 @@ export class WorkflowEngine {
6730
6774
  context: this.context?.(),
6731
6775
  priority: task.priority,
6732
6776
  attempt: task.attempt,
6733
- }, task.transport);
6777
+ };
6778
+ if (task.enqueuedAt)
6779
+ remote[DISPATCH_KEY] = dispatchKey(task.stepId, task.attempt, task.enqueuedAt);
6780
+ return this.pool.dispatch(remote, task.transport);
6734
6781
  }
6735
6782
  /** A turn's read of its run's checkpoints: {@link StateStore.listCheckpointsForReplay}, or every
6736
6783
  * column through {@link StateStore.listCheckpoints} on a store without it. */
@@ -6822,10 +6869,9 @@ export class WorkflowEngine {
6822
6869
  const { cp, run } = settled;
6823
6870
  if (await this.stepFanStillRunning(cp, result, queuedIn))
6824
6871
  return;
6825
- await this.markResumeOwed(run);
6826
6872
  if (!parseStepFanGroup(cp.parallelGroup))
6827
6873
  this.noteSettledHere(result.runId, [cp.seq]);
6828
- this.resumeInBackground(result.runId, fanWakeOf(cp, result));
6874
+ await this.resumeOwed(run, result.runId, fanWakeOf(cp, result));
6829
6875
  }
6830
6876
  /**
6831
6877
  * Record `seqs` of `runId` in {@link settledHere}, dropping the entries older than a lease. A key is
@@ -6846,43 +6892,156 @@ export class WorkflowEngine {
6846
6892
  }
6847
6893
  }
6848
6894
  /**
6849
- * Make the turn a settled step result owes its run DURABLE before the result is acknowledged.
6895
+ * Resume the run a settled step result owes a turn, and make that turn DURABLE before the result
6896
+ * is acknowledged (the caller's return is the acknowledgement).
6850
6897
  *
6851
6898
  * Settling a result does two things — settle the checkpoint, then resume the run — and only the
6852
- * first is durable: the resume runs in the background, after the transport has acknowledged the
6853
- * result. A pod SIGKILLed in between (or mid-way through the resumed turn, which keeps the run
6854
- * `suspended` and so is invisible to the orphan sweep) left the run parked on whatever `wakeAt` its
6855
- * last turn wrote — for a step that is the reconcile fallback, minutes out — with nothing else to
6856
- * wake it: the result was already acknowledged, so no redelivery re-drives it.
6899
+ * first is durable on its own: the resume runs in the background, so the transport is never held
6900
+ * by a turn that awaits another result (the self-deadlock the background resume exists for). A pod
6901
+ * SIGKILLed after the acknowledgement and before (or mid-way through) the resumed turn, which keeps
6902
+ * the run `suspended` and so is invisible to the orphan sweep, left the run parked on whatever
6903
+ * `wakeAt` its last turn wrote — for a step that is the reconcile fallback, minutes out — with
6904
+ * nothing else to wake it: the result was already acknowledged, so no redelivery re-drives it.
6857
6905
  *
6858
- * So pull the suspended run's timer in to one lease from now first. The resume this instance is
6859
- * about to run re-parks the run (or settles it) and so overwrites it; if this instance dies before
6860
- * that, every instance's due-timer poll resumes the run once the lease a dying turn may hold has
6861
- * lapsed. Leased like any due run, so a live turn is never doubled — at worst a resume landing after
6862
- * the timer replays and re-parks. Only pulls a timer IN (one already due sooner is kept), and only
6863
- * for a `suspended` run: a run still `running` has its first turn holding the lease, which re-checks
6864
- * the step on settling (see `recheckWaitSeqs`) and is recovered by the orphan sweep if it dies.
6906
+ * So the acknowledgement waits for one of two things, at most {@link OWED_RESUME_GRACE_MS}:
6907
+ * - the resumed turn ending here: it settled or re-parked the run itself, so nothing is owed any
6908
+ * more — the common case, and it costs no write;
6909
+ * - else ({@link OWED_RESUME_GRACE_MS} passed, or the resume failed) a durable mark: for a
6910
+ * `suspended` run {@link markResumeOwed} pulls its timer in to one lease from now, so every
6911
+ * instance's due-timer poll resumes it once the lease a dying turn may hold has lapsed; for a run
6912
+ * whose turn is still in flight, a {@link requestWake} stamp (see {@link owedDurably}). A resume
6913
+ * refused by another instance's lease already made that stamp, which is then enough.
6914
+ * A pod dying while the acknowledgement waits never acknowledged: the transport redelivers the
6915
+ * result, whose settled checkpoint re-drives the resume on a live instance.
6865
6916
  *
6866
- * Best-effort: a store that refuses the write leaves the run exactly where it was before.
6917
+ * The wait is bounded, so a turn that awaits another result in memory holds this one for at most
6918
+ * the grace before it is marked and acknowledged — never a deadlock.
6919
+ */
6920
+ async resumeOwed(run, runId, fan) {
6921
+ let served = false;
6922
+ let refusal;
6923
+ const resumed = this.resumeImplicit(runId, fan).then((result) => {
6924
+ refusal = leaseRefused.get(result);
6925
+ served = refusal === undefined;
6926
+ }, (err) => {
6927
+ console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
6928
+ });
6929
+ this.trackEffect(resumed);
6930
+ const owed = this.owedDurably(run);
6931
+ if (owed === undefined)
6932
+ return;
6933
+ let timer;
6934
+ await Promise.race([
6935
+ resumed,
6936
+ new Promise((resolve) => {
6937
+ timer = setTimeout(resolve, OWED_RESUME_GRACE_MS);
6938
+ }),
6939
+ ]);
6940
+ clearTimeout(timer);
6941
+ if (served)
6942
+ return;
6943
+ // Refused: the wake already asked the lease holder for a turn, durably, unless that failed.
6944
+ if (refusal !== undefined && (await refusal) !== 'failed')
6945
+ return;
6946
+ if (owed === 'mark') {
6947
+ await this.markResumeOwed(run, () => served);
6948
+ return;
6949
+ }
6950
+ if ((await this.stampWakeRequest(runId, { generic: true, fans: new Map() })) === 'failed') {
6951
+ console.warn(`[adonis-durable] could not mark run ${runId} as owed a turn (it recovers on its next reconcile wake)`);
6952
+ }
6953
+ }
6954
+ /**
6955
+ * What makes the turn a result settled for `run` (as read when it settled) owes durable, if the
6956
+ * resume does not end in time: `mark` ({@link markResumeOwed}) for a `suspended` run whose timer is
6957
+ * not already due within a lease; `stamp` (a {@link requestWake} stamp) for a run whose turn is in
6958
+ * flight (`running`, or `blocked`) — its turn may park it after this, overwriting any timer, and a
6959
+ * holder that dies after parking leaves a `suspended` run the orphan sweep never sees, so only the
6960
+ * stamp (kept across the park, and due for the timer poll at once) survives both. Else nothing.
6867
6961
  */
6868
- async markResumeOwed(run) {
6962
+ owedDurably(run) {
6963
+ if (run?.status === 'suspended')
6964
+ return this.owesMark(run) ? 'mark' : undefined;
6965
+ if (run?.status === 'running' || run?.status === 'blocked')
6966
+ return 'stamp';
6967
+ return undefined;
6968
+ }
6969
+ /**
6970
+ * Whether a result settled for `run` (as read when it settled) would need {@link markResumeOwed}: only
6971
+ * a `suspended` run whose timer is not already due within a lease (a run whose turn is in flight
6972
+ * gets a wake request instead, see {@link owedDurably}).
6973
+ */
6974
+ owesMark(run) {
6869
6975
  if (run?.status !== 'suspended')
6976
+ return false;
6977
+ return run.wakeAt === undefined || run.wakeAt > this.clock() + this.leaseMs;
6978
+ }
6979
+ /**
6980
+ * Mark `run` as owed a resume: pull its timer in to one lease from now (only IN — a timer already
6981
+ * due sooner is kept, and only while the run is still `suspended`). Leased like any due run, so a
6982
+ * live turn is never doubled — at worst a resume that lands after the mark replays and re-parks.
6983
+ * `served` reports a resume that ended meanwhile, whose mark is then dropped if not yet written.
6984
+ *
6985
+ * Batched across runs when the store has {@link StateStore.pullRunWakesIn}. Best-effort: a store that
6986
+ * refuses the write leaves the run exactly where it was before.
6987
+ */
6988
+ async markResumeOwed(run, served = () => false) {
6989
+ if (!this.owesMark(run))
6870
6990
  return;
6871
- const owedBy = this.clock() + this.leaseMs;
6872
- if (run.wakeAt !== undefined && run.wakeAt <= owedBy)
6991
+ if (!this.store.pullRunWakesIn) {
6992
+ await this.markRunResumeOwed(run.id, this.clock() + this.leaseMs);
6873
6993
  return;
6994
+ }
6995
+ // Batched across runs: the results of a busy instance arrive many runs at a time (a results batch
6996
+ // settles each of its runs concurrently), and each would otherwise cost its own UPDATE. The first
6997
+ // mark goes out at once; the ones arriving while it is in flight go out together in the next.
6998
+ await new Promise((done) => {
6999
+ this.owedResumes.push({ runId: run.id, served, done });
7000
+ if (!this.markingOwedResumes)
7001
+ void this.markOwedResumes();
7002
+ });
7003
+ }
7004
+ /** Write the queued owed-resume marks, a batch at a time, until none is left (see {@link markResumeOwed}). */
7005
+ async markOwedResumes() {
7006
+ this.markingOwedResumes = true;
7007
+ try {
7008
+ while (this.owedResumes.length > 0) {
7009
+ const batch = this.owedResumes.splice(0, OWED_RESUME_BATCH_MAX);
7010
+ // One lease from the write, not from each mark's arrival: never later than a single write.
7011
+ const owedBy = this.clock() + this.leaseMs;
7012
+ // A resume that ended while its mark queued owes nothing any more.
7013
+ const runIds = batch.filter((entry) => !entry.served()).map((entry) => entry.runId);
7014
+ if (runIds.length === 0) {
7015
+ for (const entry of batch)
7016
+ entry.done();
7017
+ continue;
7018
+ }
7019
+ try {
7020
+ await this.store.pullRunWakesIn?.(runIds, owedBy);
7021
+ }
7022
+ catch {
7023
+ // A batch the store refused (a deadlock victim, a dropped connection) is retried one run at
7024
+ // a time, so one bad row costs no other run its mark.
7025
+ for (const runId of new Set(runIds))
7026
+ await this.markRunResumeOwed(runId, owedBy);
7027
+ }
7028
+ for (const entry of batch)
7029
+ entry.done();
7030
+ }
7031
+ }
7032
+ finally {
7033
+ this.markingOwedResumes = false;
7034
+ }
7035
+ }
7036
+ /** {@link markResumeOwed} for one run, through {@link StateStore.updateRunIf}. Best-effort. */
7037
+ async markRunResumeOwed(runId, owedBy) {
6874
7038
  try {
6875
- await this.store.updateRunIf(run.id, ['suspended'], { wakeAt: owedBy });
7039
+ await this.store.updateRunIf(runId, ['suspended'], { wakeAt: owedBy });
6876
7040
  }
6877
7041
  catch (err) {
6878
- console.warn(`[adonis-durable] could not mark run ${run.id} as owed a resume (it recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
7042
+ console.warn(`[adonis-durable] could not mark run ${runId} as owed a resume (it recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
6879
7043
  }
6880
7044
  }
6881
- resumeInBackground(runId, fan) {
6882
- this.trackEffect(this.resumeImplicit(runId, fan).catch((err) => {
6883
- console.warn(`[adonis-durable] background resume of run ${runId} failed (the run recovers on its next reconcile wake): ${err instanceof Error ? err.message : String(err)}`);
6884
- }));
6885
- }
6886
7045
  /**
6887
7046
  * Settle a batch of one run's step results; per result, `true` (settled or dropped) or the error
6888
7047
  * that kept it from settling, which the transport then redelivers. Same outcome per result as
@@ -6998,9 +7157,8 @@ export class WorkflowEngine {
6998
7157
  for (const { cp, result } of resumable) {
6999
7158
  const fan = parseStepFanGroup(cp.parallelGroup);
7000
7159
  if (!fan || result.status === 'failed') {
7001
- await this.markResumeOwed(run);
7002
7160
  this.noteSettledHere(runId, singles);
7003
- this.resumeInBackground(runId);
7161
+ await this.resumeOwed(run, runId);
7004
7162
  return outcomes;
7005
7163
  }
7006
7164
  let closed = barriers.get(fan.firstSeq);
@@ -7012,9 +7170,8 @@ export class WorkflowEngine {
7012
7170
  barriers.set(fan.firstSeq, closed);
7013
7171
  }
7014
7172
  if (!(await closed)) {
7015
- await this.markResumeOwed(run);
7016
7173
  this.noteSettledHere(runId, singles);
7017
- this.resumeInBackground(runId, {
7174
+ await this.resumeOwed(run, runId, {
7018
7175
  first: fan.firstSeq,
7019
7176
  seq: highest.get(fan.firstSeq) ?? cp.seq,
7020
7177
  });