Difficulty: Expert
Type: performance (scalability)
Background
app/admin/members/page.tsx's executeBulkAssign() handles the bulk role-assignment flow (triggered from components/ui/bulk-action-toolbar.tsx after selecting multiple members). It calls Promise.allSettled(items.map(async (item) => { await api.assignRole(item.address, item.role); ... })) — every selected member's assignRole() call is fired concurrently, with no limit on how many are in flight at once. Separately, lib/api/live.ts already implements a per-path circuit breaker (CircuitEntry, assertCircuitAllowsRequest, recordCircuitFailure) that opens after CIRCUIT_FAILURE_THRESHOLD (default 3) failures within CIRCUIT_FAILURE_WINDOW_MS (default 30s) for a given request path, and REST retry logic (backoffDelayMs) — but both of these only apply per-request; shouldRetryRequest() also only retries GET requests by design, so assignRole (a POST) is never retried automatically.
Problem
For a moderately large community, an admin could plausibly select dozens or hundreds of members and trigger a bulk role change (the UI has a "select all on page" affordance per pageAddresses.forEach). Firing that many concurrent POST /v1/members/:address/roles requests at once:
- Provides no client-side throttling, so it can behave like a self-inflicted burst against the backend, independent of whatever server-side capacity exists.
- Interacts poorly with the existing circuit breaker: once 3 of these concurrent requests fail within the 30-second window (e.g., the backend is genuinely struggling under the burst), the circuit for that path opens and every other in-flight request in the same batch immediately fails with a
503 serviceUnavailableError, even though those requests might have succeeded individually at a lower concurrency — so a single problematic backend response can cascade into a much larger batch failure than necessary, and the admin sees a confusing wall of both real errors and circuit-breaker-induced 503s in the same "results" list with no way to distinguish them.
- Gives no backpressure signal back to the UI to slow down or pause mid-batch — it's all-or-nothing per the initial
Promise.allSettled call.
Expected Outcome
Bulk role assignment processes selected members in bounded-concurrency chunks (not fully sequential — that would be too slow for the "select all" case — and not fully parallel), pauses/backs off automatically when the shared circuit breaker for the assignRole path opens (rather than continuing to fire requests that are guaranteed to fail), and clearly distinguishes in the results UI between "failed at the backend" and "skipped because the circuit breaker was open" so admins get an accurate, actionable picture and can safely retry just the right subset.
Suggested Implementation
- Add a small, dependency-free concurrency-limited batch runner (e.g.,
lib/api/batch-runner.ts, exporting something like runWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>)), since the project has no existing utility for this (lodash is available per the frontend/React artifact tooling docs, but check whether it's actually a listed dependency of this app in package.json before reaching for _.chunk/a manual queue — prefer a small hand-rolled implementation if not already a dependency, to avoid adding one for this alone).
- Expose a way to observe the circuit breaker's current state for a given path from
lib/api/live.ts without changing its existing per-request behavior (e.g., a new exported isCircuitOpen(path: string): boolean, reading from the existing circuitBreakers map/getCircuit() helper) — this is additive and shouldn't change any existing REST call semantics.
- In
executeBulkAssign() (app/admin/members/page.tsx), replace the single Promise.allSettled(items.map(...)) call with the new bounded-concurrency runner, using a sensible default limit (e.g., 5–10 concurrent requests — make it a named constant, not a magic number).
- Before/between chunks, check
isCircuitOpen() for the role-assignment path; if it's open, stop dispatching new requests in this batch and mark all remaining, not-yet-attempted items in the results as a distinct status (e.g., "skipped_circuit_open", separate from "error"), rather than firing them into guaranteed 503s.
- Update the bulk-results UI (and the "retry failed items" flow already present, given
setBulkFailedItems) to visually and semantically distinguish ok / error / skipped_circuit_open, and make "retry failed/skipped items" work correctly for both categories once the circuit closes again.
- Add unit tests for the batch runner in isolation (concurrency is actually bounded; all items eventually settle; ordering/results integrity) and integration-style tests for
executeBulkAssign()'s new circuit-breaker-aware behavior (mock assignRole to fail enough times to open the circuit mid-batch, assert remaining items are marked skipped rather than attempted).
Acceptance Criteria
Likely Affected Files/Directories
app/admin/members/page.tsx
lib/api/live.ts
lib/api/batch-runner.ts (new)
components/ui/bulk-action-toolbar.tsx
test/live-api-resilience.test.ts
test/ (new test file for the batch runner and bulk-assign behavior)
Difficulty: Expert
Type: performance (scalability)
Background
app/admin/members/page.tsx'sexecuteBulkAssign()handles the bulk role-assignment flow (triggered fromcomponents/ui/bulk-action-toolbar.tsxafter selecting multiple members). It callsPromise.allSettled(items.map(async (item) => { await api.assignRole(item.address, item.role); ... }))— every selected member'sassignRole()call is fired concurrently, with no limit on how many are in flight at once. Separately,lib/api/live.tsalready implements a per-path circuit breaker (CircuitEntry,assertCircuitAllowsRequest,recordCircuitFailure) that opens afterCIRCUIT_FAILURE_THRESHOLD(default 3) failures withinCIRCUIT_FAILURE_WINDOW_MS(default 30s) for a given request path, and REST retry logic (backoffDelayMs) — but both of these only apply per-request;shouldRetryRequest()also only retriesGETrequests by design, soassignRole(aPOST) is never retried automatically.Problem
For a moderately large community, an admin could plausibly select dozens or hundreds of members and trigger a bulk role change (the UI has a "select all on page" affordance per
pageAddresses.forEach). Firing that many concurrentPOST /v1/members/:address/rolesrequests at once:503serviceUnavailableError, even though those requests might have succeeded individually at a lower concurrency — so a single problematic backend response can cascade into a much larger batch failure than necessary, and the admin sees a confusing wall of both real errors and circuit-breaker-induced503s in the same "results" list with no way to distinguish them.Promise.allSettledcall.Expected Outcome
Bulk role assignment processes selected members in bounded-concurrency chunks (not fully sequential — that would be too slow for the "select all" case — and not fully parallel), pauses/backs off automatically when the shared circuit breaker for the
assignRolepath opens (rather than continuing to fire requests that are guaranteed to fail), and clearly distinguishes in the results UI between "failed at the backend" and "skipped because the circuit breaker was open" so admins get an accurate, actionable picture and can safely retry just the right subset.Suggested Implementation
lib/api/batch-runner.ts, exporting something likerunWithConcurrency<T, R>(items: T[], limit: number, worker: (item: T) => Promise<R>)), since the project has no existing utility for this (lodashis available per the frontend/React artifact tooling docs, but check whether it's actually a listed dependency of this app inpackage.jsonbefore reaching for_.chunk/a manual queue — prefer a small hand-rolled implementation if not already a dependency, to avoid adding one for this alone).lib/api/live.tswithout changing its existing per-request behavior (e.g., a new exportedisCircuitOpen(path: string): boolean, reading from the existingcircuitBreakersmap/getCircuit()helper) — this is additive and shouldn't change any existing REST call semantics.executeBulkAssign()(app/admin/members/page.tsx), replace the singlePromise.allSettled(items.map(...))call with the new bounded-concurrency runner, using a sensible default limit (e.g., 5–10 concurrent requests — make it a named constant, not a magic number).isCircuitOpen()for the role-assignment path; if it's open, stop dispatching new requests in this batch and mark all remaining, not-yet-attempted items in the results as a distinct status (e.g.,"skipped_circuit_open", separate from"error"), rather than firing them into guaranteed503s.setBulkFailedItems) to visually and semantically distinguishok/error/skipped_circuit_open, and make "retry failed/skipped items" work correctly for both categories once the circuit closes again.executeBulkAssign()'s new circuit-breaker-aware behavior (mockassignRoleto fail enough times to open the circuit mid-batch, assert remaining items are marked skipped rather than attempted).Acceptance Criteria
assignRolerequests in flight at once.isCircuitOpen()(or equivalently named) export fromlib/api/live.tsdoes not alter any existing REST call's retry/circuit-breaker behavior — all existing tests intest/live-api-resilience.test.tscontinue to pass unmodified.npm run typecheck,npm run lint,npm testpass.Likely Affected Files/Directories
app/admin/members/page.tsxlib/api/live.tslib/api/batch-runner.ts(new)components/ui/bulk-action-toolbar.tsxtest/live-api-resilience.test.tstest/(new test file for the batch runner and bulk-assign behavior)