@remit/web-client 0.0.269 → 0.0.271
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/package.json +1 -1
- package/src/components/mail/MessageList.run-reporting.test.ts +1 -1
- package/src/components/mail/SelectionWizardHost.tsx +15 -50
- package/src/components/mail/bulk-run-state.test.ts +100 -0
- package/src/components/mail/bulk-run-state.ts +99 -0
- package/src/hooks/escalated-run-lifetime.render.test.ts +87 -6
- package/src/hooks/update-restart-escalation.render.test.ts +340 -0
- package/src/hooks/use-system-update.ts +49 -2
- package/src/hooks/useEscalatedActions.ts +45 -24
- package/src/lib/bulk-action-copy.test.ts +14 -4
- package/src/lib/bulk-action-copy.ts +5 -3
- package/src/lib/bulk-actions.test.ts +115 -26
- package/src/lib/bulk-actions.ts +40 -10
- package/src/lib/error-classifier.test.ts +59 -0
- package/src/lib/error-classifier.ts +48 -2
- package/src/lib/self-update-state.test.ts +1 -0
- package/src/lib/self-update-state.ts +138 -30
|
@@ -11,6 +11,24 @@ import {
|
|
|
11
11
|
runPredicateAction,
|
|
12
12
|
} from "./bulk-actions.js";
|
|
13
13
|
|
|
14
|
+
/** A run nobody stops. */
|
|
15
|
+
const neverAborted = (): AbortSignal => new AbortController().signal;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* What an aborted request does: reject with the signal's own reason, whether it
|
|
19
|
+
* was already aborted when the call was made or aborted while in flight.
|
|
20
|
+
*/
|
|
21
|
+
const abortable = (signal: AbortSignal): Promise<never> =>
|
|
22
|
+
new Promise((_, reject) => {
|
|
23
|
+
if (signal.aborted) {
|
|
24
|
+
reject(signal.reason);
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
signal.addEventListener("abort", () => reject(signal.reason), {
|
|
28
|
+
once: true,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
14
32
|
const ids = (count: number, prefix = "m"): string[] =>
|
|
15
33
|
Array.from({ length: count }, (_, i) => `${prefix}${i}`);
|
|
16
34
|
|
|
@@ -112,7 +130,6 @@ describe("chunkTargets", () => {
|
|
|
112
130
|
});
|
|
113
131
|
|
|
114
132
|
describe("runChunkedAction", () => {
|
|
115
|
-
const neverCancelled = () => false;
|
|
116
133
|
const noopProgress = () => undefined;
|
|
117
134
|
|
|
118
135
|
test("zero ids does nothing and reports done=0", async () => {
|
|
@@ -124,7 +141,7 @@ describe("runChunkedAction", () => {
|
|
|
124
141
|
return { successCount: chunk.length, failureCount: 0 };
|
|
125
142
|
},
|
|
126
143
|
noopProgress,
|
|
127
|
-
|
|
144
|
+
neverAborted(),
|
|
128
145
|
);
|
|
129
146
|
assert.deepEqual(outcome, {
|
|
130
147
|
done: 0,
|
|
@@ -144,7 +161,7 @@ describe("runChunkedAction", () => {
|
|
|
144
161
|
return { successCount: chunk.length, failureCount: 0 };
|
|
145
162
|
},
|
|
146
163
|
noopProgress,
|
|
147
|
-
|
|
164
|
+
neverAborted(),
|
|
148
165
|
);
|
|
149
166
|
assert.equal(calls.length, 2);
|
|
150
167
|
assert.equal(calls[0].length, BULK_ACTION_CHUNK_SIZE);
|
|
@@ -159,7 +176,7 @@ describe("runChunkedAction", () => {
|
|
|
159
176
|
input,
|
|
160
177
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
161
178
|
noopProgress,
|
|
162
|
-
|
|
179
|
+
neverAborted(),
|
|
163
180
|
);
|
|
164
181
|
assert.equal(outcome.done, input.length);
|
|
165
182
|
assert.deepEqual(outcome.failedIds, []);
|
|
@@ -168,16 +185,16 @@ describe("runChunkedAction", () => {
|
|
|
168
185
|
test("cancelling mid-run folds every unreached chunk into failedIds", async () => {
|
|
169
186
|
const input = targets(BULK_ACTION_CHUNK_SIZE * 3);
|
|
170
187
|
let calls = 0;
|
|
171
|
-
|
|
188
|
+
const stop = new AbortController();
|
|
172
189
|
const outcome = await runChunkedAction(
|
|
173
190
|
input,
|
|
174
191
|
async (chunk) => {
|
|
175
192
|
calls++;
|
|
176
|
-
if (calls === 1)
|
|
193
|
+
if (calls === 1) stop.abort(); // cancel after the first chunk lands
|
|
177
194
|
return { successCount: chunk.length, failureCount: 0 };
|
|
178
195
|
},
|
|
179
196
|
() => undefined,
|
|
180
|
-
|
|
197
|
+
stop.signal,
|
|
181
198
|
);
|
|
182
199
|
assert.equal(outcome.cancelled, true);
|
|
183
200
|
assert.equal(calls, 1);
|
|
@@ -195,7 +212,7 @@ describe("runChunkedAction", () => {
|
|
|
195
212
|
throw boom;
|
|
196
213
|
},
|
|
197
214
|
noopProgress,
|
|
198
|
-
|
|
215
|
+
neverAborted(),
|
|
199
216
|
);
|
|
200
217
|
assert.equal(outcome.error, boom);
|
|
201
218
|
assert.equal(outcome.done, 0);
|
|
@@ -215,7 +232,7 @@ describe("runChunkedAction", () => {
|
|
|
215
232
|
return { successCount: chunk.length, failureCount: 0 };
|
|
216
233
|
},
|
|
217
234
|
noopProgress,
|
|
218
|
-
|
|
235
|
+
neverAborted(),
|
|
219
236
|
);
|
|
220
237
|
assert.deepEqual(calls, [["a1", "a2"], ["b1"]]);
|
|
221
238
|
assert.equal(outcome.done, 3);
|
|
@@ -231,7 +248,7 @@ describe("runChunkedAction", () => {
|
|
|
231
248
|
],
|
|
232
249
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
233
250
|
(p) => seen.push(p),
|
|
234
|
-
|
|
251
|
+
neverAborted(),
|
|
235
252
|
);
|
|
236
253
|
assert.deepEqual(seen, [
|
|
237
254
|
{ done: 1, total: 2 },
|
|
@@ -240,7 +257,7 @@ describe("runChunkedAction", () => {
|
|
|
240
257
|
});
|
|
241
258
|
|
|
242
259
|
test("cancelling at an account boundary hands back the accounts never reached", async () => {
|
|
243
|
-
|
|
260
|
+
const stop = new AbortController();
|
|
244
261
|
const outcome = await runChunkedAction(
|
|
245
262
|
[
|
|
246
263
|
{ id: "a1", accountId: "acct-a" },
|
|
@@ -248,11 +265,11 @@ describe("runChunkedAction", () => {
|
|
|
248
265
|
{ id: "c1", accountId: "acct-c" },
|
|
249
266
|
],
|
|
250
267
|
async (chunk) => {
|
|
251
|
-
|
|
268
|
+
stop.abort();
|
|
252
269
|
return { successCount: chunk.length, failureCount: 0 };
|
|
253
270
|
},
|
|
254
271
|
() => undefined,
|
|
255
|
-
|
|
272
|
+
stop.signal,
|
|
256
273
|
);
|
|
257
274
|
assert.equal(outcome.cancelled, true);
|
|
258
275
|
assert.equal(outcome.done, 1);
|
|
@@ -271,7 +288,7 @@ describe("runChunkedAction", () => {
|
|
|
271
288
|
return { successCount: chunk.length, failureCount: 0 };
|
|
272
289
|
},
|
|
273
290
|
() => undefined,
|
|
274
|
-
|
|
291
|
+
neverAborted(),
|
|
275
292
|
);
|
|
276
293
|
assert.equal(outcome.error, boom);
|
|
277
294
|
assert.equal(outcome.done, 1);
|
|
@@ -291,7 +308,7 @@ describe("runChunkedAction", () => {
|
|
|
291
308
|
throw new Error("auth expired");
|
|
292
309
|
},
|
|
293
310
|
() => undefined,
|
|
294
|
-
|
|
311
|
+
neverAborted(),
|
|
295
312
|
);
|
|
296
313
|
// The run stops where it threw rather than trying the accounts behind it:
|
|
297
314
|
// what it hands back is exactly what is still untouched, which is what a
|
|
@@ -308,7 +325,7 @@ describe("runChunkedAction", () => {
|
|
|
308
325
|
input,
|
|
309
326
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
310
327
|
(p) => progressCalls.push(p),
|
|
311
|
-
|
|
328
|
+
neverAborted(),
|
|
312
329
|
);
|
|
313
330
|
assert.deepEqual(progressCalls, [
|
|
314
331
|
{ done: BULK_ACTION_CHUNK_SIZE, total: input.length },
|
|
@@ -318,7 +335,6 @@ describe("runChunkedAction", () => {
|
|
|
318
335
|
});
|
|
319
336
|
|
|
320
337
|
describe("runPredicateAction", () => {
|
|
321
|
-
const neverCancelled = () => false;
|
|
322
338
|
const noopProgress = () => undefined;
|
|
323
339
|
|
|
324
340
|
/** Builds a paged fixture: `pages[i]` is what the i-th call returns. */
|
|
@@ -338,7 +354,7 @@ describe("runPredicateAction", () => {
|
|
|
338
354
|
0,
|
|
339
355
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
340
356
|
noopProgress,
|
|
341
|
-
|
|
357
|
+
neverAborted(),
|
|
342
358
|
);
|
|
343
359
|
assert.deepEqual(outcome, { done: 0, failedIds: [], cancelled: false });
|
|
344
360
|
});
|
|
@@ -354,7 +370,7 @@ describe("runPredicateAction", () => {
|
|
|
354
370
|
return { successCount: chunk.length, failureCount: 0 };
|
|
355
371
|
},
|
|
356
372
|
noopProgress,
|
|
357
|
-
|
|
373
|
+
neverAborted(),
|
|
358
374
|
);
|
|
359
375
|
assert.equal(deleteCalls.length, 1);
|
|
360
376
|
assert.equal(outcome.done, BULK_ACTION_CHUNK_SIZE);
|
|
@@ -374,7 +390,7 @@ describe("runPredicateAction", () => {
|
|
|
374
390
|
return { successCount: chunk.length, failureCount: 0 };
|
|
375
391
|
},
|
|
376
392
|
noopProgress,
|
|
377
|
-
|
|
393
|
+
neverAborted(),
|
|
378
394
|
);
|
|
379
395
|
assert.equal(deleteCalls.length, 2);
|
|
380
396
|
assert.equal(outcome.done, 150);
|
|
@@ -397,7 +413,7 @@ describe("runPredicateAction", () => {
|
|
|
397
413
|
return { successCount: chunk.length, failureCount: 0 };
|
|
398
414
|
},
|
|
399
415
|
noopProgress,
|
|
400
|
-
|
|
416
|
+
neverAborted(),
|
|
401
417
|
);
|
|
402
418
|
assert.deepEqual(deleteCalls, [
|
|
403
419
|
["a", "b"],
|
|
@@ -408,7 +424,7 @@ describe("runPredicateAction", () => {
|
|
|
408
424
|
|
|
409
425
|
test("cancelling mid-delete stops paging without inventing failedIds for unfetched pages", async () => {
|
|
410
426
|
let fetchCalls = 0;
|
|
411
|
-
|
|
427
|
+
const stop = new AbortController();
|
|
412
428
|
const fetch = async (): Promise<FetchIdsPageResult> => {
|
|
413
429
|
fetchCalls++;
|
|
414
430
|
return { ids: ["a", "b"], continuationToken: "more" };
|
|
@@ -417,11 +433,11 @@ describe("runPredicateAction", () => {
|
|
|
417
433
|
fetch,
|
|
418
434
|
1000,
|
|
419
435
|
async (chunk) => {
|
|
420
|
-
|
|
436
|
+
stop.abort(); // cancel takes effect on the next loop iteration
|
|
421
437
|
return { successCount: chunk.length, failureCount: 0 };
|
|
422
438
|
},
|
|
423
439
|
() => undefined,
|
|
424
|
-
|
|
440
|
+
stop.signal,
|
|
425
441
|
);
|
|
426
442
|
assert.equal(outcome.cancelled, true);
|
|
427
443
|
assert.equal(fetchCalls, 1);
|
|
@@ -439,7 +455,7 @@ describe("runPredicateAction", () => {
|
|
|
439
455
|
100,
|
|
440
456
|
async (chunk) => ({ successCount: chunk.length, failureCount: 0 }),
|
|
441
457
|
noopProgress,
|
|
442
|
-
|
|
458
|
+
neverAborted(),
|
|
443
459
|
);
|
|
444
460
|
assert.equal(outcome.error, boom);
|
|
445
461
|
assert.equal(outcome.done, 0);
|
|
@@ -455,12 +471,85 @@ describe("runPredicateAction", () => {
|
|
|
455
471
|
throw boom;
|
|
456
472
|
},
|
|
457
473
|
noopProgress,
|
|
458
|
-
|
|
474
|
+
neverAborted(),
|
|
459
475
|
);
|
|
460
476
|
assert.equal(outcome.error, boom);
|
|
461
477
|
});
|
|
462
478
|
});
|
|
463
479
|
|
|
480
|
+
// Regression for #113: a stop used to be a flag read between pages, so the
|
|
481
|
+
// search or delete already on the wire ran to completion — up to a hundred
|
|
482
|
+
// messages deleted after the press. The run's signal is now the call's signal.
|
|
483
|
+
describe("stopping a run that has a request in flight", () => {
|
|
484
|
+
test("a bounded run reports the aborted batch as a clean stop, never an error", async () => {
|
|
485
|
+
const stop = new AbortController();
|
|
486
|
+
const calls: string[][] = [];
|
|
487
|
+
const outcome = await runChunkedAction(
|
|
488
|
+
targets(BULK_ACTION_CHUNK_SIZE * 3),
|
|
489
|
+
async (chunk, signal) => {
|
|
490
|
+
calls.push(chunk);
|
|
491
|
+
stop.abort();
|
|
492
|
+
return abortable(signal);
|
|
493
|
+
},
|
|
494
|
+
() => undefined,
|
|
495
|
+
stop.signal,
|
|
496
|
+
);
|
|
497
|
+
assert.equal(outcome.cancelled, true);
|
|
498
|
+
assert.equal(outcome.error, undefined);
|
|
499
|
+
assert.equal(calls.length, 1, "nothing further left after the stop");
|
|
500
|
+
assert.equal(outcome.done, 0);
|
|
501
|
+
// The aborted batch may or may not have been accepted server-side, so it
|
|
502
|
+
// comes back with the chunks never attempted rather than counted as done.
|
|
503
|
+
assert.equal(outcome.failedIds.length, BULK_ACTION_CHUNK_SIZE * 3);
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
test("a predicate run whose delete is aborted sends nothing more and reports a clean stop", async () => {
|
|
507
|
+
const stop = new AbortController();
|
|
508
|
+
let fetches = 0;
|
|
509
|
+
let deletes = 0;
|
|
510
|
+
const outcome = await runPredicateAction(
|
|
511
|
+
async () => {
|
|
512
|
+
fetches++;
|
|
513
|
+
return { ids: ["a", "b"], continuationToken: "more" };
|
|
514
|
+
},
|
|
515
|
+
1000,
|
|
516
|
+
async (_chunk, signal) => {
|
|
517
|
+
deletes++;
|
|
518
|
+
stop.abort();
|
|
519
|
+
return abortable(signal);
|
|
520
|
+
},
|
|
521
|
+
() => undefined,
|
|
522
|
+
stop.signal,
|
|
523
|
+
);
|
|
524
|
+
assert.equal(outcome.cancelled, true);
|
|
525
|
+
assert.equal(outcome.error, undefined);
|
|
526
|
+
assert.equal(deletes, 1);
|
|
527
|
+
assert.equal(fetches, 1);
|
|
528
|
+
assert.equal(outcome.done, 0);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test("a predicate run whose search page is aborted deletes nothing", async () => {
|
|
532
|
+
const stop = new AbortController();
|
|
533
|
+
let deletes = 0;
|
|
534
|
+
const outcome = await runPredicateAction(
|
|
535
|
+
async (_token, signal) => {
|
|
536
|
+
stop.abort();
|
|
537
|
+
return abortable(signal);
|
|
538
|
+
},
|
|
539
|
+
1000,
|
|
540
|
+
async (chunk) => {
|
|
541
|
+
deletes++;
|
|
542
|
+
return { successCount: chunk.length, failureCount: 0 };
|
|
543
|
+
},
|
|
544
|
+
() => undefined,
|
|
545
|
+
stop.signal,
|
|
546
|
+
);
|
|
547
|
+
assert.equal(outcome.cancelled, true);
|
|
548
|
+
assert.equal(outcome.error, undefined);
|
|
549
|
+
assert.equal(deletes, 0);
|
|
550
|
+
});
|
|
551
|
+
});
|
|
552
|
+
|
|
464
553
|
describe("honestProgress", () => {
|
|
465
554
|
// Regression for #109: the count and `runPredicateAction` resolve the same
|
|
466
555
|
// predicate independently, so the delete can outrun the frozen `total` it
|
package/src/lib/bulk-actions.ts
CHANGED
|
@@ -15,6 +15,18 @@
|
|
|
15
15
|
* failure (auth, the write, or the enqueue) that takes out the whole batch and
|
|
16
16
|
* stops the run.
|
|
17
17
|
*
|
|
18
|
+
* Cancellation is an `AbortSignal` the caller owns, handed to every call the run
|
|
19
|
+
* makes (#113). Aborting ends the request in flight rather than waiting for it,
|
|
20
|
+
* so at most the batch already on the wire is at stake instead of a whole page;
|
|
21
|
+
* the run then stops before the next request leaves. A rejection raised while
|
|
22
|
+
* the signal is aborted is that abort, so it reports `cancelled` rather than an
|
|
23
|
+
* error — the user asked for it.
|
|
24
|
+
*
|
|
25
|
+
* That classification reads `signal.aborted`, deliberately, and not the shape of
|
|
26
|
+
* the rejection (`isAbortError`): a genuine failure that raced the user's Stop
|
|
27
|
+
* should read as a stop. The user pressed it and the run is over either way, and
|
|
28
|
+
* an error banner for a run they ended themselves is the wrong answer.
|
|
29
|
+
*
|
|
18
30
|
* Pure, framework-agnostic, and independently testable — no React, no fetch.
|
|
19
31
|
* `useEscalatedActions.ts` supplies the real `ApplyBatch`/`FetchIdsPage`
|
|
20
32
|
* implementations (the generated SDK client) and owns the React state.
|
|
@@ -22,6 +34,9 @@
|
|
|
22
34
|
|
|
23
35
|
export const BULK_ACTION_CHUNK_SIZE = 100;
|
|
24
36
|
|
|
37
|
+
/** For a run with no Stop control to wire a signal to. */
|
|
38
|
+
const NEVER_ABORTED = new AbortController().signal;
|
|
39
|
+
|
|
25
40
|
/**
|
|
26
41
|
* Resolves a promise to a discriminated result instead of throwing, so a
|
|
27
42
|
* caller can branch on infrastructure failure as a value — the app's
|
|
@@ -104,7 +119,10 @@ export interface BatchResult {
|
|
|
104
119
|
}
|
|
105
120
|
|
|
106
121
|
/** Applies the action to one batch of ids (≤100) with a single bulk call. */
|
|
107
|
-
export type ApplyBatch = (
|
|
122
|
+
export type ApplyBatch = (
|
|
123
|
+
ids: string[],
|
|
124
|
+
signal: AbortSignal,
|
|
125
|
+
) => Promise<BatchResult>;
|
|
108
126
|
|
|
109
127
|
export interface BulkActionProgress {
|
|
110
128
|
/** Ids the action has been applied to so far. */
|
|
@@ -122,6 +140,9 @@ export interface BulkActionOutcome {
|
|
|
122
140
|
* cancellation or a thrown error, the chunks the bounded run never attempted
|
|
123
141
|
* (see `runChunkedAction`). Empty in the predicate case, which re-resolves on
|
|
124
142
|
* every run rather than handing back a remainder (see `runPredicateAction`).
|
|
143
|
+
* An aborted batch is counted here too: aborting the request does not
|
|
144
|
+
* un-send what the server already accepted, so its ids are "may not have
|
|
145
|
+
* landed" rather than "did not", and every action here is idempotent.
|
|
125
146
|
* There is no per-id failure source: a returned batch call counts every id in
|
|
126
147
|
* it as accepted (see the module header).
|
|
127
148
|
*/
|
|
@@ -150,7 +171,7 @@ export const runChunkedAction = async (
|
|
|
150
171
|
targets: readonly BulkActionTarget[],
|
|
151
172
|
applyBatch: ApplyBatch,
|
|
152
173
|
onProgress: (progress: BulkActionProgress) => void,
|
|
153
|
-
|
|
174
|
+
signal: AbortSignal,
|
|
154
175
|
): Promise<BulkActionOutcome> => {
|
|
155
176
|
const chunks = chunkTargets(targets);
|
|
156
177
|
const total = targets.length;
|
|
@@ -158,14 +179,15 @@ export const runChunkedAction = async (
|
|
|
158
179
|
const failedIds: string[] = [];
|
|
159
180
|
|
|
160
181
|
for (let i = 0; i < chunks.length; i++) {
|
|
161
|
-
if (
|
|
182
|
+
if (signal.aborted) {
|
|
162
183
|
failedIds.push(...chunks.slice(i).flat());
|
|
163
184
|
return { done, failedIds, cancelled: true };
|
|
164
185
|
}
|
|
165
186
|
const chunk = chunks[i];
|
|
166
|
-
const attempted = await attempt(applyBatch(chunk));
|
|
187
|
+
const attempted = await attempt(applyBatch(chunk, signal));
|
|
167
188
|
if (!attempted.ok) {
|
|
168
189
|
failedIds.push(...chunks.slice(i).flat());
|
|
190
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
169
191
|
onProgress({ done, total });
|
|
170
192
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
171
193
|
}
|
|
@@ -194,6 +216,10 @@ export const runChunkedAction = async (
|
|
|
194
216
|
* focused row, or a selection the surface already scoped — so nothing here
|
|
195
217
|
* carries an account to split by. A caller with a cross-account selection
|
|
196
218
|
* belongs on `runChunkedAction`, whose targets name one (#872).
|
|
219
|
+
*
|
|
220
|
+
* These callers have no Stop control to wire a signal to — a mutation over a
|
|
221
|
+
* handful of ids has no progress surface to stop from — so the run is handed a
|
|
222
|
+
* signal that never aborts.
|
|
197
223
|
*/
|
|
198
224
|
export const runChunkedMutation = async (
|
|
199
225
|
ids: readonly string[],
|
|
@@ -206,7 +232,7 @@ export const runChunkedMutation = async (
|
|
|
206
232
|
return { successCount: chunk.length, failureCount: 0 };
|
|
207
233
|
},
|
|
208
234
|
() => {},
|
|
209
|
-
|
|
235
|
+
NEVER_ABORTED,
|
|
210
236
|
);
|
|
211
237
|
};
|
|
212
238
|
|
|
@@ -218,6 +244,7 @@ export interface FetchIdsPageResult {
|
|
|
218
244
|
/** Fetches one page of matching message ids for the active predicate. */
|
|
219
245
|
export type FetchIdsPage = (
|
|
220
246
|
continuationToken: string | undefined,
|
|
247
|
+
signal: AbortSignal,
|
|
221
248
|
) => Promise<FetchIdsPageResult>;
|
|
222
249
|
|
|
223
250
|
/**
|
|
@@ -230,33 +257,36 @@ export type FetchIdsPage = (
|
|
|
230
257
|
* `failedIds` for the unreached remainder, because those ids were never
|
|
231
258
|
* fetched — there is nothing to hand back. A predicate resolves fresh on
|
|
232
259
|
* every run (D2), so resuming is re-invoking this same function with the same
|
|
233
|
-
* predicate.
|
|
260
|
+
* predicate. Aborting also ends the page or batch on the wire, so the run gives
|
|
261
|
+
* up at most the one request rather than the whole page it was mid-way through.
|
|
234
262
|
*/
|
|
235
263
|
export const runPredicateAction = async (
|
|
236
264
|
fetchIdsPage: FetchIdsPage,
|
|
237
265
|
total: number,
|
|
238
266
|
applyBatch: ApplyBatch,
|
|
239
267
|
onProgress: (progress: BulkActionProgress) => void,
|
|
240
|
-
|
|
268
|
+
signal: AbortSignal,
|
|
241
269
|
): Promise<BulkActionOutcome> => {
|
|
242
270
|
let done = 0;
|
|
243
271
|
const failedIds: string[] = [];
|
|
244
272
|
let token: string | undefined;
|
|
245
273
|
|
|
246
274
|
do {
|
|
247
|
-
if (
|
|
275
|
+
if (signal.aborted) {
|
|
248
276
|
return { done, failedIds, cancelled: true };
|
|
249
277
|
}
|
|
250
278
|
|
|
251
|
-
const fetched = await attempt(fetchIdsPage(token));
|
|
279
|
+
const fetched = await attempt(fetchIdsPage(token, signal));
|
|
252
280
|
if (!fetched.ok) {
|
|
281
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
253
282
|
return { done, failedIds, cancelled: false, error: fetched.error };
|
|
254
283
|
}
|
|
255
284
|
const page = fetched.value;
|
|
256
285
|
|
|
257
286
|
if (page.ids.length > 0) {
|
|
258
|
-
const attempted = await attempt(applyBatch(page.ids));
|
|
287
|
+
const attempted = await attempt(applyBatch(page.ids, signal));
|
|
259
288
|
if (!attempted.ok) {
|
|
289
|
+
if (signal.aborted) return { done, failedIds, cancelled: true };
|
|
260
290
|
return { done, failedIds, cancelled: false, error: attempted.error };
|
|
261
291
|
}
|
|
262
292
|
done += page.ids.length;
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
isNetworkError,
|
|
10
10
|
isNotFound,
|
|
11
11
|
isServerError,
|
|
12
|
+
restartExpectedMeta,
|
|
12
13
|
shouldEscalate,
|
|
13
14
|
softErrorStatuses,
|
|
14
15
|
} from "./error-classifier";
|
|
@@ -366,6 +367,13 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
|
366
367
|
);
|
|
367
368
|
});
|
|
368
369
|
|
|
370
|
+
it("does not open the restart gap for a call site that never claimed one", () => {
|
|
371
|
+
assert.equal(
|
|
372
|
+
shouldEscalate(new ApiError("bad gateway", 502), ownsNotFound, "nobody"),
|
|
373
|
+
true,
|
|
374
|
+
);
|
|
375
|
+
});
|
|
376
|
+
|
|
369
377
|
it("names the record's absence, whichever client raised it", () => {
|
|
370
378
|
assert.equal(isNotFound(new ApiError("gone", 404)), true);
|
|
371
379
|
assert.equal(isNotFound({ status: 404 }), true);
|
|
@@ -373,3 +381,54 @@ describe("meta.softErrorStatuses (one outcome owned, the rest not)", () => {
|
|
|
373
381
|
assert.equal(isNotFound(new TypeError("x is undefined")), false);
|
|
374
382
|
});
|
|
375
383
|
});
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* A self-update stops and starts the backend, so for those seconds the proxy in
|
|
387
|
+
* front of it has nothing to dial. That gap belongs to the surface that asked
|
|
388
|
+
* for the restart; every other 5xx, on that call site and on any other, is still
|
|
389
|
+
* the API reporting itself broken (#468).
|
|
390
|
+
*/
|
|
391
|
+
describe("meta.restartExpected (the gap in a restart this call site asked for)", () => {
|
|
392
|
+
it("does NOT escalate the gateway statuses answered while the backend is down", () => {
|
|
393
|
+
for (const status of [502, 503, 504]) {
|
|
394
|
+
assert.equal(
|
|
395
|
+
shouldEscalate(
|
|
396
|
+
new ApiError("gateway", status),
|
|
397
|
+
restartExpectedMeta,
|
|
398
|
+
"nobody",
|
|
399
|
+
),
|
|
400
|
+
false,
|
|
401
|
+
`a ${status} inside the restart window belongs to the update surface`,
|
|
402
|
+
);
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("escalates a 500 — a server that answered is up, and up and broken is not designed", () => {
|
|
407
|
+
assert.equal(
|
|
408
|
+
shouldEscalate(new ApiError("boom", 500), restartExpectedMeta, "nobody"),
|
|
409
|
+
true,
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
it("escalates the same 502 without the claim, however soft the call site is", () => {
|
|
414
|
+
assert.equal(
|
|
415
|
+
shouldEscalate(new ApiError("gateway", 502), undefined, "nobody"),
|
|
416
|
+
true,
|
|
417
|
+
);
|
|
418
|
+
assert.equal(
|
|
419
|
+
shouldEscalate(new ApiError("gateway", 502), { softError: true }, "user"),
|
|
420
|
+
true,
|
|
421
|
+
);
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it("escalates a client bug raised inside the window", () => {
|
|
425
|
+
assert.equal(
|
|
426
|
+
shouldEscalate(
|
|
427
|
+
new TypeError("x is undefined"),
|
|
428
|
+
restartExpectedMeta,
|
|
429
|
+
"nobody",
|
|
430
|
+
),
|
|
431
|
+
true,
|
|
432
|
+
);
|
|
433
|
+
});
|
|
434
|
+
});
|
|
@@ -143,6 +143,33 @@ const isSoftErrorMeta = (
|
|
|
143
143
|
return status !== undefined && owned.includes(status);
|
|
144
144
|
};
|
|
145
145
|
|
|
146
|
+
/**
|
|
147
|
+
* The statuses answered for the seconds a service is not there. Every `/api/*`
|
|
148
|
+
* request is proxied to apisix, which stays up while the backend behind it
|
|
149
|
+
* restarts and refuses in milliseconds — the 29ms 502 in #468, not a dial Caddy
|
|
150
|
+
* waited out. 503 and 504 are the same gap through a hop that answers
|
|
151
|
+
* differently.
|
|
152
|
+
*/
|
|
153
|
+
const RESTART_GAP_STATUSES = [502, 503, 504];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The gap in a restart this call site asked for. A self-update stops and starts
|
|
157
|
+
* the backend on purpose, so the poll watching that run meets a proxy with
|
|
158
|
+
* nothing behind it — the designed shape of the operation the user pressed, not
|
|
159
|
+
* our API answering "I'm broken".
|
|
160
|
+
*
|
|
161
|
+
* A 500 is not in it: a server that answered is a server that is up, and up and
|
|
162
|
+
* broken is never designed.
|
|
163
|
+
*/
|
|
164
|
+
const isDesignedRestartGap = (
|
|
165
|
+
error: unknown,
|
|
166
|
+
meta: Record<string, unknown> | undefined,
|
|
167
|
+
): boolean => {
|
|
168
|
+
if (meta?.restartExpected !== true) return false;
|
|
169
|
+
const status = getErrorStatus(error);
|
|
170
|
+
return status !== undefined && RESTART_GAP_STATUSES.includes(status);
|
|
171
|
+
};
|
|
172
|
+
|
|
146
173
|
/**
|
|
147
174
|
* The single fail-fast decision: should this error escalate to the full-screen
|
|
148
175
|
* fatal overlay? Default is YES — a non-2xx must never silently vanish.
|
|
@@ -151,7 +178,10 @@ const isSoftErrorMeta = (
|
|
|
151
178
|
* 1. DEFAULT = escalate.
|
|
152
179
|
* 2. A 5xx (500–599) ALWAYS escalates — no opt-out, even on a background
|
|
153
180
|
* refetch, even when the call site marked `meta.softError`. Our API
|
|
154
|
-
* answered "I'm broken"; that is never benign.
|
|
181
|
+
* answered "I'm broken"; that is never benign. The single exception is a
|
|
182
|
+
* 502/503/504 on a call site that declared it is polling across a restart it
|
|
183
|
+
* asked for (`meta.restartExpected`, #468): there the backend being gone is
|
|
184
|
+
* the operation working, and that call site renders the wait itself.
|
|
155
185
|
* 3. A client-side exception ALWAYS escalates, on the same terms. It is our
|
|
156
186
|
* bug; there is nothing for the user to retry and nothing to dismiss.
|
|
157
187
|
* 4. A 401 on a request the user is waiting on ALWAYS escalates, on the same
|
|
@@ -173,7 +203,7 @@ export const shouldEscalate = (
|
|
|
173
203
|
meta?: Record<string, unknown>,
|
|
174
204
|
awaiting: Awaiting = "nobody",
|
|
175
205
|
): boolean => {
|
|
176
|
-
if (isServerError(error)) return
|
|
206
|
+
if (isServerError(error)) return !isDesignedRestartGap(error, meta);
|
|
177
207
|
if (isAbortError(error)) return false;
|
|
178
208
|
if (isNetworkError(error)) return false;
|
|
179
209
|
if (awaiting === "user" && isUnauthenticated(error)) return true;
|
|
@@ -209,6 +239,22 @@ export const softErrorStatuses = (
|
|
|
209
239
|
...statuses: number[]
|
|
210
240
|
): { softErrorStatuses: number[] } => ({ softErrorStatuses: statuses });
|
|
211
241
|
|
|
242
|
+
/**
|
|
243
|
+
* The one opt-out from rule 2, for a request watching a restart the run it is
|
|
244
|
+
* following performs. Set it only while a run is known to be in flight — the
|
|
245
|
+
* update poll carries it while it holds a run it started or the server's last
|
|
246
|
+
* answer reported one going — because a 502 outside that window is the API being
|
|
247
|
+
* broken, which is exactly what rule 2 is for.
|
|
248
|
+
*
|
|
249
|
+
* A call site that sets this owns the whole wait, so the window outlives the
|
|
250
|
+
* budget: the give-up screen keeps polling a server it has stopped expecting,
|
|
251
|
+
* and a fatal page thrown over that verdict would replace the one screen that
|
|
252
|
+
* names the run and the log to read. A 500 from that same poll still escalates.
|
|
253
|
+
*/
|
|
254
|
+
export const restartExpectedMeta: { restartExpected: true } = {
|
|
255
|
+
restartExpected: true,
|
|
256
|
+
};
|
|
257
|
+
|
|
212
258
|
/** The record is not there. */
|
|
213
259
|
export const isNotFound = (error: unknown): boolean =>
|
|
214
260
|
getErrorStatus(error) === 404;
|