+ That's more than one retry carries out, so they go in {rounds} rounds of up to{" "}
+ {plan!.limit.toLocaleString()}, one after another. Keep this page open until they finish.
+
+ )}
+
+ {progress && (
+
+
+ Retried {progress.done.toLocaleString()} of {progress.total.toLocaleString()}…
+
@@ -240,23 +278,33 @@ export function RetryDialog({
)}
- {
- setReset(e.target.checked);
- onResetChange?.(e.target.checked);
- }}
- />
+ {/* Not once rounds are under way: they keep the choice they started with. */}
+ {!progress && (
+ {
+ setReset(e.target.checked);
+ onResetChange?.(e.target.checked);
+ }}
+ />
+ )}
>
)}
+ {error && (
+
+ {error}
+
+ )}
+
- {!plan?.overLimit && !nothingToDo && (
+ {/* Not again after a failure: the plan it would repeat is out of date by the rounds already done. */}
+ {!plan?.overLimit && !nothingToDo && !error && (
diff --git a/docs/api-reference.md b/docs/api-reference.md
index e603b7b8..43a88a50 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -100,7 +100,7 @@ Exchange search filters, besides ordinary fields:
| `StatusFilter:1:{n}` | 0 processing, 1 success, 2 bad response, 3 failed. Other values are refused. |
| `LatestOnly:1:true` | Only the newest attempt of each retry chain |
-Bulk retry takes `{ ids, filter, excludeIds, reason, reset }`. `filter` is the same query-string fragment the search takes, such as `filter=StatusFilter:1:3&filter=LatestOnly:1:true`, and it replaces `ids` when present. The plan returned by both endpoints lists how many were selected and will be retried, attempts substituted with the end of their chain, and skipped exchanges with reasons. At most 500 exchanges can be retried at once.
+Bulk retry takes `{ ids, filter, excludeIds, reason, reset }`. `filter` is the same query-string fragment the search takes, such as `filter=StatusFilter:1:3&filter=LatestOnly:1:true`, and it replaces `ids` when present. The plan returned by both endpoints lists how many were selected and will be retried, attempts substituted with the end of their chain, and skipped exchanges with reasons, plus `targets`, the attempts that will run. The retry carries out at most `limit` (500) per request; the preview plans up to `maxSelection` (5,000), so a larger selection is retried by sending `targets` back as `ids`, `limit` at a time.
Export takes `{ ids, filter, excludeIds }`, chosen the same way, and streams back a zip (`application/zip`) with a folder per exchange and a `missing.txt` listing files that storage no longer has. At most 500 exchanges go in one zip; a larger selection is refused with `TOO_MANY`. `export/check` takes the same body and returns `{ count, withoutFiles, keptDays }`: how many exchanges the zip would take, how many of them are older than the bucket's rule keeps files, and that rule's days when one covers them all.
diff --git a/docs/retries-and-alerts.md b/docs/retries-and-alerts.md
index 327be913..19be7445 100644
--- a/docs/retries-and-alerts.md
+++ b/docs/retries-and-alerts.md
@@ -22,7 +22,7 @@ Content-Type: application/json
`POST /api/xchanges/bulkretry` retries either a list of `ids`, or everything a search `filter` matches minus `excludeIds`. `POST /api/xchanges/bulkretrypreview` returns the same plan without running it, and the UI shows that plan before you confirm.
-- A bulk retry covers at most 500 exchanges. A larger selection is refused, so narrow the filter.
+- One retry request carries out at most 500 exchanges. The preview works out up to 5,000 and lists the attempts that will run in `targets`; the UI sends them back as `ids` in rounds of 500 and shows how far it has got. If a round fails, the rounds before it are done and the rest are not retried. Past 5,000, narrow the filter.
- An exchange that was already retried is replaced by the newest attempt of its chain. Two selections that lead to the same attempt are retried once.
- The plan skips, with a reason, exchanges that no longer exist, that succeeded, that have an automatic retry waiting, whose subscription is gone when `reset` is on, or whose input file cannot be read.
- Exchanges with no result yet are included, so work stranded by an outage can be recovered. Make sure nothing is still processing before retrying it.
From 64f4dc5ec68f777a4f0f492cf0c43138b0967a53 Mon Sep 17 00:00:00 2001
From: Hamza Alqurneh
Date: Tue, 20 Oct 2026 12:39:56 +0300
Subject: [PATCH 2/3] perf: track bulk retry targets in a set
---
SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs | 4 +++-
1 file changed, 3 insertions(+), 1 deletion(-)
diff --git a/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs b/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs
index 0a4d0150..8fc46cf0 100644
--- a/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs
+++ b/SW.Bitween.Api/Resources/Xchanges/BulkRetryPlanner.cs
@@ -88,6 +88,8 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo)
var plan = new XchangeBulkRetryPlan { Selected = selected.Count, Limit = Limit, MaxSelection = MaxSelection };
var targets = new List();
+ // The list keeps the order; the set answers "already in it" without walking up to MaxSelection entries.
+ var targeted = new HashSet();
foreach (var selectedId in selected)
{
@@ -109,7 +111,7 @@ internal async Task Prepare(XchangeBulkRetry request, int upTo)
// Two selections in the same chain - an exchange and its own retry, say - come to the
// same attempt, which is retried once.
- if (!targets.Contains(targetId))
+ if (targeted.Add(targetId))
targets.Add(targetId);
}
From e900de8e180d40bea0371b0c5c136644a75b0cff Mon Sep 17 00:00:00 2001
From: Hamza Alqurneh
Date: Tue, 20 Oct 2026 12:39:56 +0300
Subject: [PATCH 3/3] fix: a bulk retry finishing after its dialog closed
leaves a newer one alone
---
.../src/pages/exchanges/ExchangesPage.tsx | 17 ++++++++++---
.../__tests__/ExchangesList.test.tsx | 25 +++++++++++++++++++
2 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
index 91db4a5b..504dbfc0 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/ExchangesPage.tsx
@@ -309,6 +309,11 @@ export function ExchangesPage() {
const [bulkProgress, setBulkProgress] = useState<{ done: number; total: number } | null>(null);
/** Set when the confirm is closed mid-way: the rounds stop after the one under way. */
const stopRounds = useRef(false);
+ /**
+ * The run the open confirm belongs to. Closing it moves this on, so a run that ends afterwards
+ * reports what it did but can't close a confirm opened since, or clear a selection made since.
+ */
+ const bulkRun = useRef(0);
/**
* A selection past what one request carries out: the preview listed every attempt that will run,
@@ -347,11 +352,14 @@ export function ExchangesPage() {
plan && plan.selected > plan.limit
? retryInRounds(plan, reset)
: api.bulkRetryExchanges(selection, { reset }).then((done) => ({ ...done, stoppedAt: null })),
- onSuccess: (done) => {
- setBulkConfirm(false);
- setBulkReset(false);
+ onMutate: () => ({ run: ++bulkRun.current }),
+ onSuccess: (done, _reset, started) => {
setBulkProgress(null);
- clearSelection();
+ if (started?.run === bulkRun.current) {
+ setBulkConfirm(false);
+ setBulkReset(false);
+ clearSelection();
+ }
setBulkResult(
`${done.willRetry.toLocaleString()} retr${done.willRetry === 1 ? "y" : "ies"} started` +
(done.substituted.length > 0 ? `, ${done.substituted.length} continuing an existing chain` : "") +
@@ -707,6 +715,7 @@ export function ExchangesPage() {
onResetChange={setBulkReset}
onClose={() => {
stopRounds.current = true;
+ bulkRun.current++;
setBulkConfirm(false);
setBulkProgress(null);
// Not while rounds are still under way: Retry would be offered again, and a second run
diff --git a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx
index 0bf64158..d45757f9 100644
--- a/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx
+++ b/SW.Bitween.Web/ClientApp/src/pages/exchanges/__tests__/ExchangesList.test.tsx
@@ -231,6 +231,31 @@ describe("the exchanges list", () => {
expect(retry.sent.map((r) => r.ids)).toEqual([targets.slice(0, 2), targets.slice(2, 4), targets.slice(4)]);
});
+ it("closed mid-way, stops after the round under way and leaves a confirm opened since alone", async () => {
+ const retry = rounds({ holds: 1 });
+ const { user } = renderApp("/exchanges?status=failed", {
+ handlers: [exchanges({ total: 60 }).handler, ...filterOptions, preview, retry.handler],
+ });
+
+ await selectAllMatching(user);
+ await user.click(screen.getByRole("button", { name: "Retry selected…" }));
+ const dialog = await screen.findByRole("dialog");
+ await within(dialog).findByText(/they go in 3 rounds/);
+ await user.click(within(dialog).getByRole("button", { name: "Retry" }));
+ await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
+
+ await user.click(screen.getByRole("button", { name: "Retry selected…" }));
+ await screen.findByRole("dialog");
+ retry.release();
+
+ expect(
+ await screen.findByText("2 retries started, 1 skipped. Stopped after 2 of 5; the rest were not retried."),
+ ).toBeVisible();
+ expect(retry.sent).toHaveLength(1);
+ expect(screen.getByRole("dialog")).toBeVisible();
+ expect(screen.getByRole("button", { name: "Retry selected…" })).toBeVisible();
+ });
+
it("stops at a round that fails, says how far it got, and doesn't offer to go again", async () => {
const retry = rounds({ fails: 2 });
const { user } = renderApp("/exchanges?status=failed", {